diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..174f7990a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Keep keys and local state out of the build context: compose bind-mounts the +# checkout at run time, so the image never needs them. +.git +node_modules +dist +.env +.env.* +!.env.example +**/ENVIRONMENT +pinokio/ENVIRONMENT +pinokio/.installed +.gev-cache +.gev-logs +qa-artifacts +qa-shots +screenshots +output +3d-models +.gstack +*.log +.DS_Store +# AppleDouble sidecars from a macOS `tar` copy; `npm test` would otherwise +# collect `._*.test.mjs` as test files. +._* diff --git a/.env.example b/.env.example index c89680b30..0876262b2 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,8 @@ # (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide # them (see SECURITY.md). All other keys below stay server-side. -# Optional: direct Google Photorealistic 3D Tiles and GEV place search. +# Optional: direct Google Photorealistic 3D Tiles, GEV place search and Street +# View (Street View also needs the Maps JavaScript API enabled for the key). # Without it, a Cesium ion token can still load ion-hosted Google 3D; with # neither credential, the app starts on keyless Esri World Imagery with OSM # available in the map tray and as the automatic provider-failure fallback. @@ -82,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe # keep them while debugging; delete the log when you are done. GEV_VOICE_LOG_CONTENT= OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano +# Optional: Gemini Live voice, selected in the GEV MIC provider chooser. +# Server-only key. Do not prefix with VITE_. Provider Settings can save it. +GEMINI_API_KEY= +GEMINI_LIVE_MODEL=gemini-3.8-live +# Token starts per IP per minute; 0 disables the throttle, not provider quotas. +# GEV_RATELIMIT_GEMINI_PER_MIN=30 # Per-IP rate limit for the OpenAI cost endpoints # (/api/realtime/token + /api/openai/hud-summary), in requests per minute. # DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary @@ -95,13 +102,21 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano # spend protection set a provider-side budget: OpenAI platform -> Settings -> # Limits (usage limits). # GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it +# Optional: exact socket addresses (comma-separated, no wildcards or prefixes) +# that Provider Settings treats as this machine in addition to loopback. Leave +# unset. The Docker launcher sets it to the container's gateway at start, +# because that is where Docker delivers your own browser from; it is safe only +# while the port is published on 127.0.0.1. Read SECURITY.md before setting it +# by hand. +# GEV_KEY_SETUP_TRUSTED_PEERS= # Optional: Mapillary imagery for the Street Level layer. Free client token from # https://www.mapillary.com/dashboard/developers (register an app, copy its # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -110,11 +125,13 @@ OPENSKY_AUTH_MODE=oauth OPENSKY_CLIENT_ID= OPENSKY_CLIENT_SECRET= -# Optional: higher Launch Library 2 request allowance. Public access works -# without a token. +# Launch Library 2: leave blank for standard public access (no key needed). +# Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config @@ -193,6 +210,7 @@ AISSTREAM_API_KEY= # CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams # CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL # CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras +# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc # CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511 # CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack # CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog @@ -211,10 +229,33 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras +# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today) +# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras +# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200 +# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL +# CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras +# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog +# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras +# CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first; min 8, max 1200 # CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras +# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead) # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL +# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement (tmcdot@dot.nyc.gov) for the camera feed +# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) +# CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL +# CCTV_TORONTO_ENABLED=1 # set 0 to disable the City of Toronto RESCU cameras +# CCTV_TORONTO_MAX_SOURCES=336 # cap cameras loaded from Toronto RESCU (clamped to 8–500) +# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL +# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) +# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required +# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md) +# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first) +# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras +# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required +# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e8c9f8bdf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ +## What this changes + + + +## How I verified it + +- [ ] `npm run build` +- [ ] `npm test` +- [ ] `npm run test:track` (dev server running) +- [ ] Feature gate for the area I touched: + + + +## New layer checklist + + + +**Screenshots** + + + +**Recording** + + + +**Proposed category** + + + +Category: + +**Data source and licence** + +- Source (provider and endpoint): +- Licence or terms (link): +- Attribution shown in the app: +- Needs a key? (none / optional / required): +- Fetched at runtime, or bundled in the repo: + + + +- [ ] Added a row to `DATA_SOURCES.md` +- [ ] Credit is registered in `src/data/dataCredits.js` +- [ ] Share-link token assigned with `npm run layer-token:next`, if the layer is shareable + +## Docs + +- [ ] `docs/CURRENT-STATE.md` updated and a `changelog.d/-.md` fragment added, or this PR doesn't change runtime behaviour diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..ce3563685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,6 @@ jobs: timeout-minutes: 30 env: GEV_REQUIRE_ALLOCATION_GATE: '0' - # Fail on a missed header press unless the panel itself moved. - QA_FAIL_ON_RETRY: '1' steps: - name: Check out source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -104,10 +102,6 @@ jobs: - name: Street Level gate with Mapillary fixtures run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173 - - name: Portable panel gate - if: success() || failure() - run: npm run qa:panel-resize -- --url http://localhost:4173 - - name: Preview server log if: failure() run: cat preview.log @@ -150,7 +144,7 @@ jobs: run: npm run check:boundaries - name: Run focused onboarding tests - run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs + run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs - name: Build production bundle run: npm run build diff --git a/.gitignore b/.gitignore index 14e8d320b..11e081e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,15 @@ output/ .gev-cache/ # Browser QA gate evidence (screenshots, console and network dumps). qa-artifacts/ +# Playwright MCP session logs: browser consoles can carry tokens in URLs. +.playwright-mcp/ +# Local Claude Code settings and preview configs. +.claude/ .DS_Store .gstack/ 3d-models/ pinokio/ENVIRONMENT pinokio/.installed + +# Transient: a release interrupted mid-assembly (npm run changelog recovers it) +changelog.d/.assembling/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 90402cad5..f5d3a4842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,202 @@ # Changelog -- Add a Drone Restrictions layer for FAA prohibited (P) and restricted (R) - special-use airspace. The keyless `/api/drone-airspace` proxy pages the - public SUA FeatureServer, keeps a one-hour snapshot, and serves stale data - when a refresh fails. Zones draw as ground polygons (red prohibited, orange - restricted), selection shows name, altitudes, times of use and agency, and - the row legend states the layer is informational only. Off by default; - share-link token `3`. +- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the + civil and military layers, so a consumer reading `getTrackedInfo()` no longer + has to reach past it into the raw poll record for climb/descent. Reported, + never derived: an aircraft that has not transmitted a rate reads as `null` + rather than a level-flight `0`. + +- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted + where it could go — https, an allowlisted host, a station feed path, no + redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted + how often, so the dev server would make unlimited requests at a third-party + bikeshare operator on any caller's behalf. The route now admits 120 + requests/minute per client address, refusing the rest with 429 and + `Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a + minute with every catalogued system in range, so ordinary use is unchanged. + +- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region, + YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own + licence quote and attribution. A feed can now name its own modes, so Toronto's + trams read "Streetcar" while Helsinki's stay "Tram", and a feed whose route ids + are internal keys can translate them to the number a rider would say. TTC + vehicles take their mode from the fleet number first, so replacement buses on + streetcar routes read as buses and out-of-service streetcars stay streetcars. + +- CCTV cameras with identified hardware show the model's datasheet specs. When a + source names a camera model found in the vendored CCTV Camera Database records + (CC0), the HUD meta line adds its resolution, night vision and PTZ capability. + A fixed single-lens model's horizontal FOV fills in when the source states no + FOV and the pose is not curated (`FOV n° (DATASHEET)`); a PTZ or varifocal + range is what the lens can do, not its current zoom, so those cameras keep the + estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, + #452). + +- Ship a web app manifest and raster home-screen icons, so adding the app to a + phone home screen or installing it on desktop uses the God's Eye View mark + instead of a blank tile. The icons are generated from `public/logo.svg` by + `node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs + no image toolchain. The existing SVG favicon still serves the browser tab. + Manifest paths are relative to the manifest, so a build served under a path + prefix installs and launches inside that prefix. + +- Cyclone advisories no longer go dark while NHC publishes an advisory ahead + of its nominal time: advisory and position times up to 90 minutes ahead of + the server clock are accepted (previously 5 minutes), so the whole feed is + not rejected for about half an hour each advisory cycle. + +- Flights age out vanished aircraft again. OpenSky's routine position-less rows + no longer mark every snapshot partial, which had kept vanished and landed + aircraft at full opacity for up to five minutes; those rows retain only their + own aircraft, and a likely-landed one still leaves on the landed limit. + +- Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack: + WSDOT's official Travel Center camera layer, with frames registered only on + WSDOT's own image host (partner-hosted cameras such as Oregon DOT and City of + Seattle are skipped). Cameras with a single cardinal `CompassDirection` get + a high-confidence heading; `B` (both directions), `O`, and unset directions + use the shared id-hash fallback at low confidence. By default the 250 + cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the + cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra, + #644). + +- Refuse still-image cameras on the CCTV media relay. `/api/cctv/media` is the + live-feed route, but it also relayed any registered still camera through an + ordinary fetch that followed redirects to any host, passed through whatever + content type came back and had no cap on the body, bypassing every protection + `/api/cctv/frame` applies to the same URL. Only HLS, MP4, WebM and MJPEG + feeds are relayed now; a still camera, or an unknown id, gets a JSON 404 + without an upstream request and is served only through `/api/cctv/frame`. + The client already loaded stills through `/frame`, so nothing it requests + changes. + +- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. The pack is opt-in (`CCTV_LITHUANIA_ENABLED=1`) because its camera endpoints are the eismoinfo.lt website backend, not a service listed in Via Lietuva's open-data statement, so the licence for the photos is unconfirmed. + +- Add King County road cameras (Washington State) as a keyless CCTV source + pack: one public King County DOT ArcGIS layer, fetched with an explicit field + list so its staff-name columns are never requested, and frames pinned to the + county's own image host and upgraded to HTTPS. WSDOT-owned rows are skipped, + and city-owned cameras the county hosts credit their owner. The layer + publishes no facing, so headings use the shared id-hash fallback at low + confidence. It does publish each camera's hardware, which is served on + `/api/cctv/sources` as `model` and `manufacturer`. `CCTV_KINGCOUNTY_MAX_SOURCES` + sets the cap and `CCTV_KINGCOUNTY_ENABLED=0` turns the pack off + (bassem chagra, #645). + +- Add City of Toronto RESCU traffic cameras as a keyless CCTV source pack: the + ~336 municipal cameras the Ontario 511 pack does not carry — downtown + arterials plus the Gardiner, DVP, Allen and Lake Shore. The list is the + City's own open data feed; frames are built from the documented + `loc####.jpg` convention and a digits-only camera number, so the frame proxy + is pinned to the portal origin by construction. The dataset publishes no + camera facing — its `D1`-`D4` fields describe static 2016 comparison images, + not the live pan-tilt-zoom frame — so headings use the shared id-hash + fallback at low confidence and are corrected with the calibration gizmo. + Carries the required Open Government Licence – Toronto attribution. Disable + with `CCTV_TORONTO_ENABLED=0`. + +- Stop one retired bikeshare feed from reporting the whole layer as failed. + City feeds are fetched independently, but failure was recorded in a single + flag that only a successful *activation* cleared — and an already-loaded city + never activates again. A dead feed re-entering camera range therefore latched + LOAD FAILED permanently over a map still drawing every other city's stations. + Failure is now tracked per city: a city that fails beside others still + drawing marks the layer DEGRADED, polls that all fail over previously drawn + points read STALE without advancing freshness, and LOAD FAILED is reported + only when nothing is left to draw. A city that recovers clears its own + failure, a failure is dropped once its city leaves range or the camera rises + above the activation altitude, and late results from superseded requests are + ignored. + + Repointed the three feeds whose operators had moved: Reddy Bikeshare (Buffalo) + to Social Bicycles, Tugo (Tucson) and Trinity Metro (Fort Worth) to PBSC. + Dropped CoGo (Columbus) and Broward B-cycle (Fort Lauderdale), whose systems + have wound down — CoGo's feed reports no stations and last moved a bike in + March 2025. The three repointed feeds were verified live. + +- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry: + São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and + Curitiba (about 1,000 stations in total). These are the layer's first stations + in South America. + +- Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system + publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga + entries, so it needs no new plumbing; a registry test now holds every city's + feed URLs against the proxy host and path allowlists, so a city the proxy + would refuse fails the suite instead of rendering an empty layer. + +- GBFS v3 station names use English translations when available, then the first + usable translation or short name. Empty or malformed names no longer appear as + `[object Object]` or interrupt station loading; legacy string names remain + supported in the globe and bike-share tools. Thanks to + [Kristóf Kékesi](https://github.com/KristofKekesi) (#61). + +- Overpass and military-installation disk caches no longer serve answers from a + previously configured `OVERPASS_UPSTREAMS` instance after it is repointed. + +- A Radio Browser mirror answering 204, 205 or 304, or a status outside + 200–599, no longer crashes the local dev/preview server. Null-body statuses + get an empty body; anything else that cannot become a response fails that + mirror so the next one is tried. + +- Snow flakes sway within a fixed range instead of drifting sideways faster the + longer the app runs; the Wind setting still scales the sway. + +- The right rail ignores panels that other CSS hides, such as during an event + view, so they no longer force one-panel-at-a-time layout or collapse visible + panels. Panels the rail collapses itself are still restored. + +- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location. + +- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through + their ion-backed providers, retaining ion and per-tile attribution without + widening the Content Security Policy. + +- Add New York City traffic cameras from the NYC DOT Traffic Management Center + as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the + camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an + operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's + requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to + `webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each + camera UUID on that same host, so a catalog field cannot steer the proxy + off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest + the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs. + +- Spanish interface (`es`), translated by @meirm (#602) and reviewed against the + current English. + +- Interface localization, starting with Russian. UI text is marked with + `t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed + by the English text itself; each language is one flat JSON catalog + (`src/i18n/locales/ru.json`). The language comes from `?lang=`, the language + picker (the globe button in the bottom-right corner), then the browser + language; English stays the default and the fallback. `npm run i18n`, also + run by `npm test`, fails on missing, unused or broken translations. Built on + the i18n foundation by @meirm (#601), with the Russian catalog started + from #312. It also fails on UI text that is not marked at all. +- Street Level counts unique tiles before ranking so overlapping dateline halves + in polar views do not waste slots in the 16-tile overview budget. + +- Directions no longer shows a truncation notice for a route with exactly 200 + turns, and a roundabout at the 200th turn keeps its exit distance. + +- Weather records keep missing conditions unknown and refuse summaries without a measured temperature. + +- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. + +- Director timeline seeks cross the dateline along the short longitude arc. + +- Keep unknown GBFS availability counts neutral instead of turning null or empty + values into zero bikes. + +- Recent Imagery continues past malformed records in full catalog pages, respects the requested record limit, and reports results as truncated when that limit drops valid records. + +- Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant. + +- Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo + (HIBike) to the GBFS bikeshare registry, the layer's first stations in + Michigan. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. @@ -103,10 +293,105 @@ ## [Unreleased] +- Add a keyless Air quality layer to the Weather group, with Environment and + Climate Change Canada's AQHI as the first registered provider. Readings carry + `{ provider, scale, value, band }` so another network can be added without + inventing a shared numeric meaning across indices that do not share one; the + row and readout both state that coverage is Canada only. The WEATHER card + shows the band legend and the highest readings currently in view. AQHI is + reported as an integer from 1 with an open-ended "10+", so readings round to + that published form rather than the decimal precision the API returns. + Readings older than six hours are dropped, and the layer reports `stale` + through `getStats()` (also when every observation was dropped) so feed + provenance cannot describe an aged feed as live. Voice can switch it on and + query readings by value, band, station and observation time. + +- Bound six upstream body reads (FIRMS CSV, CelesTrak TLE groups, adsb.lol + `/v2/mil`, TomTom flow tiles, OpenSky `states/all` and adsbdb lookups) with + streaming size caps that cancel the body at the cap or the route's deadline; + the adsb.lol military poll gains a deadline of its own. An oversized OpenSky + snapshot is not retried, and an OpenSky 429 starts its cooldown before the + body is read. + - Keep later satellites when a TLE catalog entry is missing an orbital line. The parser skips the incomplete set and resumes at the next complete named set, instead of losing the rest of the group (#906). +- Satellite clouds keep Clouds only / Full as quick endpoints and add a continuous + cloud-filter slider: 0% is Full, 100% is the existing brightness key, and + intermediate values blend the keyed alpha. The slider commits on release to + avoid restaging imagery for every drag event. Filter percentages round-trip in + share links; legacy mode codes retain their old endpoint meanings. Background + panel refreshes preserve the pending drag value until release. Contributed by + [NotProniss](https://github.com/NotProniss) (#1084). + +- Street Level: Panoramax is the fourth provider, keyless. Light its chip + (orange; off by default, one provider at a time): below about 60 km the + federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a + line opens that sequence's picture nearest the click without moving the + camera, flat or 360°, with cones along the sequence around it and ‹ › / + ← → following the picture's own links. The 360° and since filters apply, + in the lines and in the lookup. The caption names each picture's author + and licence (CC BY-SA 4.0, Licence Ouverte 2.0…) and links to it in the + Panoramax viewer. HD originals load once, only when the viewer is wider + than the 2,048 px version (a panorama: expanded), and every download stops + when the photo changes; an image whose header declares more than 64 MP is + refused before it decodes. Share links carry it as + `0.m.0_0.r.google-0*kartaview-0*panoramax-1` (`0.m.0_0.r.panoramax-1` works + too). KartaView's photo viewer + is now shared (`photoViewer.js`, `panorama.js`); in it, for KartaView too, + Esc clears the selected drive before it collapses the panel. Lines are + simplified and drawn within a point budget (a tile's unsimplified lines + over Paris hung the tab), decoded in slices so the globe keeps moving, + and kept; the cones' tiles have their own connection, and the open + picture's tile draws its cones before its neighbours arrive. + +- Flights and Military Flights highlight emergency transponder broadcasts in + fleet billboards, 3D models and Cockpit contacts. Colors update when a status + starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV + reports minimum fuel and lifeguard without red tint. Cockpit names the code + or ADS-B status, and analyst/voice results retain broadcast provenance and + the latest known contact time. One omitted ADS-B status poll during a source + switch may retain the previous status if the squawk is unchanged; an explicit + clear takes effect immediately. Broadcasts are not confirmed incidents. + Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#856). + +- Street Level: KartaView is the third provider, keyless. Light its chip + (purple; off by default, one provider at a time) and zoom in below about + 6 km: the lines are the streets KartaView has photos of (z13–14 tiles from + `api.kartaview.org`). A click on a line opens the photo nearest the click + without moving the camera, flat or 360° (a small WebGL panorama: drag to + look, wheel to zoom), with cones along its drive and ‹ › / ← → to step + through it. The caption links to the full-size photo (KartaView's photo + pages fail for signed-out visitors), and the globe credits "© KartaView + contributors, CC BY-SA 4.0". Share links carry it in the provider + switches (`0.m.0_0.r.google-0*kartaview-1`). Archived photos (KartaView + answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and + pinch look around a 360° photo. The open photo's marker is a purple view + cone pointing along the drive (KartaView's own compass headings are often + 0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to + KartaView as to Mapillary: its lines are filtered by KartaView's own + server (its filtered PNG tiles, as a mask over the JSON lines), and the + photo a click opens, and the cones, by each photo's projection and upload + date. KartaView's SINCE is the upload date (all its server can filter by); + a note under the slider says so while KartaView is lit (`sinceBasis: + 'upload'` in the provider contract). + Mapillary's line coverage and sequence cones are now shared by both + (`lineCoverage.js`, `sequenceCones.js`). + +- Street Level: Google Street View is the second provider. On the Google 3D + map (Google's terms keep it off other maps), light its chip (off by + default: every panorama is billed to the Google Maps key, which needs the + Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to + open Google's own panorama in + the panel, with its arrows, the address and month in the caption, a link + to Google Maps. Share links carry it in the provider switches + (`r.google-1`). One provider is lit at a time (lighting one switches the + other off), Mapillary by default since it is free. Street View's panoramas + are all 360°, so the IMAGERY and SINCE filters apply to + Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker + is a Google-blue view cone. + - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from the auth button and reports completion, failure, timeout, or expired @@ -119,6 +404,21 @@ [TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling from #653. +- **Docker install path.** `docker compose up` builds on CI's Node 24.14.0 image, + pinned by digest, and serves the app on `http://localhost:4173` with no local + Node install. The repo is bind-mounted and the container runs as the host user + (never root), so `.env`, + `.gev-cache`, and `.gev-logs` persist on the host exactly as they do under + `npm run dev` — Provider Settings included. The launcher + (`scripts/docker-start.mjs`) trusts the container's gateway address for that + panel, since that is where Docker delivers the host's own browser from; the + opt-in behind it, `GEV_KEY_SETUP_TRUSTED_PEERS`, takes exact addresses only + and is unset (so loopback-only, unchanged) under every other launcher. + Thanks to [jklaze](https://github.com/jklaze) for finding and diagnosing the + container-to-host 403, and to [14traits](https://github.com/14traits), + [PoBruno](https://github.com/PoBruno) and [milu70hub](https://github.com/milu70hub) + for testing on Apple silicon, Docker Engine on Linux, and NAS hosts. + - Stdio servers from one install share a panel key so a page read from one process can make requests through another. Concurrent malformed-key repairs now select one winner; unavailable storage or a busy repair retains the @@ -127,21 +427,27 @@ proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding. -- Street Level: a street-level imagery layer modelled on the iD editor's photo - overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0 - imagery). One right-rail panel holds a chip per provider, shared 360°/flat and - captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and - FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit - down to a single street; on Google 3D they sit on the bare earth, refined - against the rendered mesh near the camera, so trees and buildings hide them. - Share links carry the provider switches, the filters and the panel's collapsed - state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk), - refuses cross-site requests, rate-limits per IP and backs off when Mapillary - rejects the token or rate-limits. +- Street Level: street-level imagery from providers, Mapillary first (free + client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider, + 360°/flat and captured-since filters and an embedded viewer with EXPAND and + FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and + Google 3D tiles; opening a photo frames it with one camera flight. Share + links carry the provider switches, the filters and the panel's collapsed + state. A server proxy adds the token to Mapillary coverage tiles, trims and + caches them for 24 h, refuses cross-site requests and redirects, and limits + load per IP and upstream. The panel reads KEY REQUIRED without a token and + KEY REJECTED when Mapillary refuses it. -- The CCTV and Street Level panels are portable: drag the header to float the - panel, resize it from any edge or corner, and double-click the header to dock - it again. The position persists across reloads. +- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and + Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and + Farte Bysykkel in Skien/Porsgrunn (Entur Mobility), and Bysykkel + Lillestrøm (JCDecaux), about 760 stations in all. Every feed is keyless + GBFS. `gbfs.urbansharing.com`, `api.entur.io` and `api.cyclocity.fr` join + the proxy allowlist; the last two are kept to their GBFS paths. The proxy + now sends `Client-Identifier: gods-eye-view` on every request and + `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, + and accepts Entur's extensionless feed paths. Attributed under NLOD and + Licence Ouverte. `get_bike_share` covers the six systems too. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its @@ -154,6 +460,53 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. +- Add Catalonia (Servei Català de Trànsit) as a keyless CCTV source pack: ~130 + cameras from a WFS/GML feed that aggregates SCT's own highway cameras with + hotlinked Barcelona and Terrassa municipal cameras, each partner named as a + credit beside the shared provider. Ambient-card refresh paced to 3 minutes, + matching the empirically observed upstream cadence. `CCTV_CATALONIA_MAX_SOURCES` + sets the cap and `CCTV_CATALONIA_ENABLED=0` turns the pack off. The catalog + is fetched over https with redirects refused and a 1 MiB body cap, and frame + links carrying userinfo or an explicit port, or outside the publisher's frame + directory, are rejected even on an allowlisted host. Camera ids come from + each frame's SCT camera file or image path rather than the feed's + restart-dependent feature id, and rows the feed repeats collapse to one. + +- Import your own GeoJSON, KML, KMZ and GPX files from DISPLAY or by dropping + them onto the app. Show, hide, fly to or remove each file for the session. + GeoJSON/GPX draw on the ground; KML retains its styles and explicit heights. + Large repeated-style exports use shared local pins, while recorded track + vertices and segment gaps stay intact. Imports respect newer camera gestures, + leave the shared clock alone, and report malformed or oversized files. + File bytes stay on the device; rendered overlays may appear in shared map + screenshots. Thanks to [Chase Mills](https://github.com/kapturner) (#925). + +- GNSS Integrity (Events, voice-addressable, share token `3`) draws navigation + accuracy scores from a keyless regional adsb.lol sample; aircraft-level analyst + queries are unavailable for these aggregate cells. Cells do not intercept + map clicks. Pan/zoom refreshes automatically; feed failures retain the display + and explain the next retry. Aircraft-reported quality includes MLAT-positioned + contacts after GPS loss, excludes TIS-B/MLAT-derived quality, and locates GPS-loss + evidence at the last good GNSS position when available. Scores discount one + bad aircraft per cell and retain 30 minutes of visited-view evidence; they + do not confirm jamming. See [method and limits](DATA_SOURCES.md#gnss-integrity). + Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#855). + +- Live HLS cameras play on a page opened over plain HTTP from another machine + (`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which + browsers only expose in secure contexts, so starting a live feed threw + before playback. The id now falls back to a v4 UUID built from + `crypto.getRandomValues`, which the server's lease check accepts. + +- Move Google geocoding behind the local server. Forward and reverse lookups now + go through `/api/google/geocode` and `/api/google/reverse-geocode` with the + server key, joining Places and Street View. Google's Geocoding web service + refuses referrer-restricted keys, so the previous browser call left + `SECURITY.md`'s advice and working search mutually exclusive: the bundled + browser key had to be unrestricted for search to work at all. That key now + needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim + fallback order are unchanged. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63f83e4c2..a7fe8ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,8 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i 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. +same arguments. Four have an `npm run` alias: `qa:map-source-tray`, +`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`. 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 @@ -63,12 +63,12 @@ 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 +> **CI covers only two of these 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. +> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture +> gate (`street-level-browser` job) — they need a live dev server and a +> browser. Include the applicable local run in your PR's validation evidence. ## Share-link layer tokens @@ -79,8 +79,8 @@ if its layer is later removed. New layers first use the remaining unreserved single-character digits (`0` through `9`), then the two-character base-36 namespace (`00` through `zz`). On the -current published ledger, `0`, `1` and `2` are already owned, so the next -choices are `3` through `9`, then `00`. After rebasing onto the latest `main`, run +current published ledger, `0`, `1`, `2` and `3` are already owned, so the next +choices are `4` through `9`, then `00`. After rebasing onto the latest `main`, run `npm run layer-token:next -- `, add that result to both the permanent JSON reservation ledger and registry entry, and keep the exact registered-layer count assertion up to date. The token is assigned against the merge-time base, not @@ -98,7 +98,7 @@ remove only your still-unpublished layer's registry entry and ledger row so the local registry is valid again, then rerun `npm run layer-token:next -- `. For example, if PR A publishes `0` while PR B still provisionally uses `0`, PR B removes only its own provisional entry, reruns the helper, and receives -`3`. Put `3` on PR B's registry entry and ledger row, update its pinned count +`4`. Put `4` on PR B's registry entry and ledger row, update its pinned count and token tests, then rerun `npm run layer-token:check -- --base-ref origin/main`. An invalid duplicate ledger can fail during module import before the helper prints anything; never fix that by renaming or deleting PR A's published token. @@ -108,7 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. -- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). @@ -116,7 +116,7 @@ The highest-leverage places to jump in: ## Architecture in one minute - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). -- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/.js` entries retain compatibility; new code belongs with its focused owner. +- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/.js` entries retain compatibility; new code belongs with its focused owner. - Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. @@ -126,6 +126,7 @@ The highest-leverage places to jump in: - ES modules, **2-space indent, single quotes, semicolons.** - JSDoc on exported/public functions. - Match the surrounding code — comment density, naming, and idiom. +- User-visible text goes through `t()`, `tn()` or `msg()`, or a `data-i18n*` attribute in markup, and `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md). - Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required. ## Formatting and reusable components @@ -149,9 +150,36 @@ ownership and adoption process. 1. Branch off `main`. 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. +3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 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). +6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). + +## Adding a new layer + +A new layer changes what everyone sees on the globe, so maintainers need to see +it working and know where its data comes from before reviewing the code. PRs +that add a layer must include all four of these in the description. The PR +template has a section for them. + +1. **Screenshots.** At least one of the layer on the globe, plus its info card + or panel if it has one. +2. **A screen recording**, preferably 15 to 30 seconds: turn the layer on, move + around, and open a record. Drag an `.mp4` or `.mov` into the PR description. + GitHub accepts videos up to 10 MB on free accounts, so link to a larger file + instead of attaching it. +3. **The proposed category.** Say which layer-panel group the layer belongs in. + The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today: + Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer + that isn't listed there falls under "Other layers". If none of the groups + fits, propose a new one and explain why. +4. **The data source and licence.** Name the provider and endpoint, link its + licence or terms, give the attribution shown in the app, and say whether it + needs a key and whether the data is fetched at runtime or bundled. Add the + same details to [DATA_SOURCES.md](DATA_SOURCES.md) and register the credit + in `src/data/dataCredits.js`. + +Maintainers may hold a layer PR until all four are there. ## Maintainers diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 2aa957e2b..363e1a75a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -20,11 +20,14 @@ How to read this: | **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" | | **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" | | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | -| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | +| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | -| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | +| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | 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) | @@ -32,10 +35,12 @@ How to read this: | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | +| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **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)" | +| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **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)" | -| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Drone Restrictions layer: US prohibited (P) and restricted (R) special-use airspace polygons, altitude limits, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Drone restrictions: FAA Special Use Airspace" | +| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (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 | @@ -45,6 +50,7 @@ How to read this: | **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain | | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) | +| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) | | **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) | | **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) | | **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) | @@ -56,8 +62,24 @@ How to read this: | **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" | | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | +| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | +| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | +| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | +| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | -| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) | +| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | +| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | +| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | +| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | +| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" | +| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` | +| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" | +| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" | +| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | +| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | +| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | @@ -65,6 +87,15 @@ How to read this: | **Entur** (api.entur.io) | Transit layer live vehicles, Norway | Entur open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends the `ET-Client-Name` identifying header. | Attribution to Entur (courtesy text) | | **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) | | **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) | +| **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto | +| **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use | +| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. Not one of its 80 route ids is the number on the bus, so the route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence; two generations of id run at once and both are listed. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence | +| **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" | +| **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence | +| **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence | +| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | +| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | +| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -75,13 +106,59 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei ### Notes on the live sources +- **Air quality (ECCC AQHI).** Keyless. Read from the MSC GeoMet OGC API (`api.weather.gc.ca`): the `aqhi-stations` catalog plus `aqhi-observations-realtime`. **Coverage is Canada only** — ECCC is the single registered provider, and the layer row and readout card both say so. Roughly 134 stations across ECCC's five administrative zones (Atlantic, Quebec, Ontario, Prairies & North, Pacific & Yukon). AQHI publishes hourly, so readings poll every 20 minutes and the station catalog, which changes a few times a year, is refetched only every 12 hours. Observations are requested with `latest=true`; without it the collection returns the full history, roughly 2,000 rows nationally. Readings older than six hours are dropped rather than displayed as current. + + Readings are stored as `{ provider, scale, value, band }` rather than a bare number, because air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk, the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a five-step category. Naming the index a value belongs to is what lets another network be added later without inventing a shared numeric meaning that does not exist. + + AQHI is reported by ECCC as an integer from 1 to 10 with an open-ended "10+" above that. The API returns the underlying decimal (1.08, 2.32); the layer rounds to the published form rather than displaying a precision the index does not claim. + + **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent. + +- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400. +- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000). + - **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. + + +- **GNSS integrity (adsb.lol).** A separate keyless point request samples a 250 nm + circle around the screen centre, snapped once to whole degrees. This does not + reuse the Flights fallback cache. Cache lifetime is 60 s; 429 backoff is capped + at one hour and other upstream failures cool down for 15 s. Future access may + require feeder keys under the [API terms](https://github.com/adsblol/api). + Airborne decoded ADS-B v1/v2 aircraft with a known 0–60 s position age qualify, + including MLAT-positioned aircraft still broadcasting their own quality. + TIS-B/MLAT-derived NIC and NACp are ignored. GPS-loss evidence uses + `gpsOkLat/gpsOkLon` when present, otherwise the current known position. + The proxy strips identity metadata and tags its response with classifier + `gev-nic-nacp-v2`; rows carry only integrity facts. + + The method has three separately attributed parts: + 1. **GEV classifier (unvalidated):** GPS-loss flag, aircraft-reported NACp < 8, + or aircraft-reported NIC < 7 except NIC 0 with NACp ≥ 10 (unknown containment). + The numerical minima come from US 14 CFR 91.227(c), not a published jamming + classifier. Readsb's [JSON contract](https://github.com/wiedehopf/readsb/blob/dev/README-json.md) + describes the quality field origins and GPS-loss flag. + 2. **GPSJAM aggregation:** score = `100 × max(0, bad − 1) / total`, with percent + bands < 2, 2–10, > 10. One bad aircraft is discounted. This is not the raw + share of bad aircraft. See the [GPSJAM FAQ](https://gpsjam.org/faq). + 3. **GEV window:** 30 minutes over visited views, at least three aircraft per + 0.5° cell. These bands were developed for daily totals; with fewer than ten + observations a cell can skip the middle band. Source timestamps date evidence + even through cache replays, and degraded evidence expires independently. + + Medium/high cells report navigation-integrity anomalies. Interference is one + possible explanation, not a detected or located emitter. Avionics faults and + manoeuvring military trainers can also lower reported accuracy. Blank areas + can be outside coverage or lack enough observations. Attribution: adsb.lol + contributors, ODbL 1.0; GPSJAM for aggregation only. + + - **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. +- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. 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. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does. - **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms. - **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. - **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. @@ -91,10 +168,18 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. +- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. +- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. +- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. +- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. +- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. +- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover. +- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. +- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **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. +- **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 sixteen 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. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **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 non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. 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 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. @@ -123,7 +208,10 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) | | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | +| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) | +| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | +| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -149,6 +237,17 @@ commercial use with attribution and applicable database share-alike/access obligations; it does not license the application code. Trimmed test-only tile fixtures retain their provenance in `src/data/fixtures/README.md`. +### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) + +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of +camera models transcribed from manufacturer datasheets, dedicated to the public domain under +**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King +County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +fetched at runtime. The records describe camera products (optics, resolution, night vision) +only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD +credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. + ### Bundled OpenStreetMap data Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. @@ -183,6 +282,11 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver wiring (see below). +### Sky background (`sky/`) + +Sources, licences, the changes CC BY asks to state, hashes and build commands +are in [`sky/README.md`](src/data/local_data/sky/README.md). + ### DataSF Analysis Neighborhoods (`neighborhoods/`) `neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..433d170a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 +# +# The dev server is the whole app: Vite serves the client AND runs the API +# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there +# is nothing else to containerise. See compose.yaml for the usual entry point. +# Same Node as CI (package.json engines, .github/workflows), pinned by digest. +FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8 + +# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download. +ENV PUPPETEER_SKIP_DOWNLOAD=1 + +# `npm test` builds fixtures with `git init`, so the suite cannot run in a +# container without it (71 failures on node:24-slim, all ENOENT on git). +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* +# The repo is bind-mounted, so inside the container it can show an owner other +# than the user the container runs as - always, on Docker Desktop. git then +# refuses it as "dubious ownership", which fails the tests that shell out to +# git. It is the user's own checkout, so trust that one path. +RUN git config --system --add safe.directory /app + +# Never run as root. The image's own user is `node` (1000); compose overrides +# it with the host user so bind-mounted files stay yours. +WORKDIR /app +RUN chown node:node /app +USER node + +COPY --chown=node:node package.json package-lock.json ./ +RUN npm ci +# node_modules belongs to `node`, but compose may run as another UID. Vite only +# needs to write its dep-optimizer cache, so open up those two directories +# rather than chowning a few hundred MB to a UID we cannot know at build time. +RUN mkdir -p node_modules/.vite && chmod 777 node_modules node_modules/.vite + +COPY --chown=node:node . . + +# Bind to all interfaces, otherwise the published port hits nothing. This does +# not widen the Host allowlist: IP addresses and localhost are accepted, other +# names only via GEV_ALLOWED_HOSTS (see build/allowedHosts.js). +ENV HOST=0.0.0.0 +ENV PORT=4173 +EXPOSE 4173 +# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is +# always on the Host allowlist, and the generous start period covers Vite's +# first-run dependency optimisation. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] +# The launcher is `npm run dev` plus one step: it trusts the container's +# gateway address for Provider Settings, because that is where Docker delivers +# the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. +CMD ["node", "scripts/docker-start.mjs"] diff --git a/README.md b/README.md index 6b8043e5a..f0a338279 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **🔭 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. +- **🔍 Keyless search:** Type decimal coordinates, a two-dimensional WGS84 `geo:` URI (for example, `geo:39.7392,-104.9903`), or a bundled city or landmark to fly there with no geocoder 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)). @@ -111,12 +111,22 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports cross-platform testing of the fixed installer. The launcher installs the -locked dependencies, finds a free local port, and opens the app. +locked dependencies, finds a free local port, and opens the app in your system +browser. The Server menu item keeps the launcher console available. **Tried before and installation failed?** Update Pinokio and try again. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). +**Using an older launcher or opening the embedded view manually?** If the globe +is very choppy, before treating that as a GPU +compatibility or minimum-hardware problem, copy the local server URL shown by +Pinokio and open the same URL in your normal hardware-accelerated browser. One +Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went +away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That points to the embedded browser rather than the app server or your +hardware; it does not establish a minimum GPU requirement. + ### Path 2 — Terminal / coding agent Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about @@ -145,6 +155,84 @@ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md). **macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any configured keys straight from the Keychain. It starts keyless too. +### Path 3 — Docker + +No Node install needed; the container publishes the same dev server. + +```bash +git clone https://github.com/bilawalsidhu/gods-eye-view.git +cd gods-eye-view +docker compose up +``` + +Open **`http://localhost:4173`** as above. Everything the app saves stays in the folder you cloned, so `docker compose down` never loses anything: + +- `.env` - your API keys, whether saved from Provider Settings or typed in by hand +- `.gev-cache/` - downloaded map and satellite data, so later starts are faster +- `.gev-logs/` - logs + +Back up that folder and you've backed up everything; delete `.env` to forget your keys. Source edits still hot-reload. The port is published to `127.0.0.1` only, so the default matches the native one. + +The local MCP server and ChatGPT OAuth voice are not available under Docker; use an OpenAI API key for voice. + +Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, and it hands out **Provider Settings** too: the launcher trusts the container gateway as this machine, and a wider publish delivers everyone else from that same address. For an instance you mean to share, put the keys in `.env` by hand instead. [SECURITY.md](SECURITY.md) has the detail. + +When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. + +```bash +GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up +``` + +Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your +shell or the project's `.env`; an exported shell value takes precedence. You can +also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting. +Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly +disables it. The startup log confirms that no additional peers are trusted. + +
+Running on Linux, a NAS, or behind a reverse proxy + +Run it as the user who should own the files it writes. `id` shows the numbers; for yourself on Linux: + +```bash +env UID=$(id -u) GID=$(id -g) docker compose up +``` + +On a NAS, clone onto the share you want the data on and use the share owner's ids: + +```bash +cd /volume1/docker +git clone https://github.com/bilawalsidhu/gods-eye-view.git +cd gods-eye-view +env UID=1026 GID=100 docker compose up -d +``` + +Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. + +On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`. + +Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). + +
+ +
+IPv6-only networks, rebuilding, and the test suites + +The launcher reads the IPv4 default gateway, so an IPv6-only Docker network +finds none: it logs `No default gateway; Provider Settings stays loopback-only` +and the panel refuses every save. Put your keys in `.env` by hand there, or name +the gateway yourself with `GEV_KEY_SETUP_TRUSTED_PEERS` after reading +[SECURITY.md](SECURITY.md). + +`node_modules` deliberately stays inside the image, so it survives in an +anonymous volume across restarts: after a `git pull` that changes dependencies, +recreate it with `docker compose down -v && docker compose up --build`. The +image also skips Puppeteer's Chromium download, which keeps it small but leaves +`npm run test:track` to the host — `npm run build` and `npm test` run fine in +the container. + +
+ ### Then power it up — in the app, not in a file Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** @@ -235,6 +323,10 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an ## 🎙️ Talk to It +The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential. + +Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary. + > Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms. @@ -255,6 +347,10 @@ Thirty tools, four jobs — the commands below come straight from the product's **🎥 Direct it** — drone-operator camera verbs: +“Rotate the globe” spins Earth to the right continuously; ask for left, a speed, +or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance; +camera input stops rotation. + > 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_ **🖊️ Annotate it** — a whiteboard over the real world: @@ -289,28 +385,31 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty-one layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | | 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | | ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | -| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🎖️ **Military Flights** | ADS-B military traffic in amber, emergency broadcasts in red | adsb.lol | 🟢 | +| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 | +| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 | | 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | | 🛰️ **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 | 🟢 | +| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **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,900 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 · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). 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) | 🟢 | -| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | +| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **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 | 🟢 | +| 🚌 **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, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | 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) | 🟢 | | 🔥 **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) | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | -| 🚫 **Drone Restrictions** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitudes and times of use on select — informational only, not for flight planning | FAA SUA | 🟢 | +| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 | | 🌬️ **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 | 🟢 | @@ -341,13 +440,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ## 📷 Street Level -> Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token. +> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key). -**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. +**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND**, **FIT / FILL**, and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. +Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. ## 🎖️ Field Missions @@ -398,7 +497,7 @@ How the globe handles live data: ``` src/ ├── main.js # Bootstrap: Google 3D tiles, layer registration -├── ui.js # Runtime UI — panels, HUD, styles, control facade +├── ui/ # Runtime UI — panels, HUD, styles, controls ├── 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 @@ -435,7 +534,7 @@ Seven keys. Five have a free tier, and the two 🔴 ones are metered: | | Key | Why | Get it | | --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | -| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it | +| 🔴 | **Google Maps** | Direct Google Photorealistic 3D, Google place search and Street View ([Map Tiles API](https://developers.google.com/maps/documentation/tile); Street View also needs the Maps JavaScript API) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it | | 🔴 | **OpenAI** | 🎙️ Voice + AI HUD summary through an API key. Provider Settings also offers **USE CHATGPT OAUTH** for cloud voice without removing the API-key option | [platform.openai.com](https://platform.openai.com) — API-key usage is metered, see costs below | | 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup | | 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | @@ -451,9 +550,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di | | Key | Why | Get it | | --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- | | 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) | -| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) | +| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) | Add these if you need higher polling allowances. +Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional +paid Patreon upgrades for higher limits; there is no free API-key signup. `npm run doctor` reports Node/npm readiness, the primary provider routes, and where each configured provider was found without printing credential values. diff --git a/SECURITY.md b/SECURITY.md index 53437a83c..4423eead3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,11 @@ God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. +Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and +the package checkout's root `dist/` directory. Dependency `dist/` directories +remain available. This filesystem deny list does not filter `public/` assets or +preview output; never put private artifacts in a public asset directory. + ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for anything exploitable. @@ -21,19 +26,19 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr | ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` | -| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | +| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | ### Three deliberately client-side keys — restrict them These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them: -1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. +1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363). 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. -3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use. +3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use. > The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side. -**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use. +**Places, Geocoding and the Street View Static fallback never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles + Maps JavaScript API, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks. Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file. @@ -69,11 +74,16 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. - **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. +- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured. +- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one. - **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). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. +- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. +- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `'; +const notes = 'A local observation with supporting detail. '.repeat(120); +const placemarks = Array.from({ length: 5000 }, (_, i) => { + const lon = -122.6 + (i % 100) * 0.002; + const lat = 37.65 + Math.floor(i / 100) * 0.003; + return `Point ${i}#base${style}${notes}${lon},${lat},0`; +}); +const wrap = (body) => + `${body}`; +const large = wrap(placemarks.join('')); +fs.writeFileSync(file, large); +const bytes = fs.statSync(file).size; +if (bytes < 20 * 1024 * 1024 || bytes > 30 * 1024 * 1024) + throw new Error(`Fixture must be 20–30 MiB, got ${bytes} bytes`); +const small = wrap( + Array.from({ length: 6 }, (_, i) => { + const kind = i % 3; + const geometry = + kind === 0 + ? '-122.49,37.77,0' + : kind === 1 + ? '-122.50,37.77,0 -122.49,37.77,0' + : '-122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0'; + const override = + kind === 0 + ? style + : kind === 1 + ? '' + : ''; + return `Small ${i}#base${override}${geometry}`; + }).join('') + + '' + + '' + + 'normal' + + ['named-a', 'named-b', 'paired'] + .map( + (id) => + `${id}#${id}-122.49,37.77,0`, + ) + .join(''), +); +const browser = await puppeteer.launch({ + headless: true, + args: + process.platform === 'darwin' + ? ['--use-gl=angle', '--use-angle=metal'] + : ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], +}); +const page = await browser.newPage(); +await hookRenderErrors(page); +const errors = []; +const fileRequests = []; +page.on('pageerror', (e) => errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request', (request) => { + if (new URL(request.url()).hostname === 'earthquake.usgs.gov') { + fileRequests.push(request.url()); + void request.abort(); + } else void request.continue(); +}); +let failures = 0; +const check = (name, ok, detail) => { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`, detail ?? ''); + if (!ok) failures++; +}; +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__gevGeoImport && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.evaluate(async () => { + const { viewer, dataManager } = window.__godsEyeView; + const C = window.__CESIUM__; + for (const id of dataManager.layers.keys()) + await dataManager.setEnabled(id, false); + viewer.clock.shouldAnimate = false; + viewer.imageryLayers.removeAll(); + viewer.terrainProvider = new C.EllipsoidTerrainProvider(); + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + window.__qaStyleSnapshot = (source) => { + const now = viewer.clock.currentTime; + const images = new Map(); + const imageValue = (p) => { + const value = p?.getValue(now); + if (value?.tagName !== 'CANVAS') return value; + if (!images.has(value)) images.set(value, value.toDataURL()); + return images.get(value); + }; + const color = (p) => p?.getValue(now)?.toCssColorString(); + const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]); + const renderedPins = new Map(); + const collection = source.importedBillboards; + if (collection) + for (let i = 0; i < collection.length; i++) { + const pin = collection.get(i); + renderedPins.set(pin.id, pin); + } + const actual = (e, key, fallback) => { + const pin = renderedPins.get(e); + return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback); + }; + return (source.importedEntities || source.entities.values) + .map((e) => ({ + name: e.name, + point: renderedPins.get(e)?.position || e.position?.getValue(now), + scale: e.billboard ? actual(e, 'scale', 1) : undefined, + rotation: e.billboard ? actual(e, 'rotation', 0) : undefined, + image: renderedPins.get(e)?.image || imageValue(e.billboard?.image), + color: e.billboard + ? actual(e, 'color', C.Color.WHITE).toCssColorString() + : undefined, + width: e.polyline?.width?.getValue(now), + lineColor: color(e.polyline?.material?.color), + line: positions(e.polyline?.positions), + fill: e.polygon?.fill?.getValue(now), + outline: e.polygon?.outline?.getValue(now), + polygonColor: color(e.polygon?.material?.color), + polygon: e.polygon?.hierarchy + ?.getValue(now) + ?.positions?.map((v) => [v.x, v.y, v.z]), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }; + window.__qaUnsharedKml = async (text) => { + const doc = new DOMParser().parseFromString(text, 'application/xml'); + for (const el of [...doc.getElementsByTagName('*')]) + if ( + ['description', 'ExtendedData', 'BalloonStyle'].includes(el.localName) + ) + el.remove(); + const source = await C.KmlDataSource.load(doc, { + camera: viewer.scene.camera, + canvas: viewer.scene.canvas, + clampToGround: true, + }); + for (const e of source.importedEntities || source.entities.values) + e.label = undefined; + return source; + }; + }); + const equivalent = await page.evaluate(async (text) => { + const { viewer } = window.__godsEyeView; + const control = await window.__qaUnsharedKml(text); + const outcomes = await window.__gevGeoImport.importFiles([ + new File([text], 'style-equivalence.kml'), + ]); + const candidate = viewer.dataSources.get(viewer.dataSources.length - 1); + const result = { + ok: outcomes[0]?.ok, + same: + JSON.stringify(window.__qaStyleSnapshot(control)) === + JSON.stringify(window.__qaStyleSnapshot(candidate)), + entities: (candidate.importedEntities || candidate.entities.values) + .length, + }; + await window.__gevGeoImport.remove(outcomes[0]?.id); + return result; + }, small); + check( + 'shared inline and named styles preserve point, line and polygon overrides/inheritance', + equivalent.ok && equivalent.same && equivalent.entities === 9, + JSON.stringify(equivalent), + ); + + // The control bypasses the former style-count refusal solely in this test, + // to compare native unshared rendering against normalized rendering. + await page.evaluate(async (text) => { + const source = await window.__qaUnsharedKml(text); + window.__qaUnsharedSnapshot = JSON.stringify( + window.__qaStyleSnapshot(source), + ); + window.__qaControl = source; + await window.__godsEyeView.viewer.dataSources.add(source); + }, large); + const measure = async () => + page.evaluate(async () => { + const C = window.__CESIUM__, + { viewer } = window.__godsEyeView; + const scene = viewer.scene; + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + const started = performance.now(), + samples = []; + let last, + frames = 0; + const remove = scene.postRender.addEventListener(() => { + const t = performance.now(); + if (t - started < 2000) return; + frames++; + if (last !== undefined) samples.push(t - last); + last = t; + }); + await new Promise((resolve) => { + let previous = performance.now(); + function tick(t) { + viewer.camera.rotate( + C.Cartesian3.UNIT_Z, + Math.min(t - previous, 100) * 0.0000002, + ); + previous = t; + scene.requestRender(); + if (t - started < 7000) requestAnimationFrame(tick); + else resolve(); + } + requestAnimationFrame(tick); + }); + remove(); + samples.sort((a, b) => a - b); + const gl = scene.context._gl, + debug = gl.getExtension('WEBGL_debug_renderer_info'); + return { + fps: frames / 5, + p50Ms: samples[Math.floor(samples.length * 0.5)], + p95Ms: samples[Math.floor(samples.length * 0.95)], + renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : null, + }; + }); + console.log('UNSHARED CONTROL', JSON.stringify(await measure())); + await page.evaluate(() => + window.__godsEyeView.viewer.dataSources.remove(window.__qaControl, true), + ); + + const loadPicked = async (filename, expected) => { + const before = await page.evaluate( + () => window.__godsEyeView.viewer.dataSources.length, + ); + const priorIds = await page.evaluate(() => + window.__gevGeoImport.list().map((f) => f.id), + ); + const started = performance.now(); + await (await page.$('#geo-import-input')).uploadFile(filename); + const name = path.basename(filename); + const loaded = await page + .waitForFunction( + ({ name, priorIds }) => + window.__gevGeoImport.list().some((f) => !priorIds.includes(f.id)) || + document + .getElementById('geo-import-hint') + .textContent.startsWith(`${name}:`), + { timeout: 120000 }, + { name, priorIds }, + ) + .then( + () => true, + () => false, + ); + const state = await page.evaluate(() => ({ + files: window.__gevGeoImport.list(), + hint: document.getElementById('geo-import-hint').textContent, + sources: window.__godsEyeView.viewer.dataSources.length, + })); + const item = state.files.find((f) => !priorIds.includes(f.id)); + check( + `picked ${name} loads`, + loaded && + (expected === null + ? item?.entityCount > 0 + : item?.entityCount === expected) && + state.sources === before + 1, + JSON.stringify({ + bytes: fs.statSync(filename).size, + loadMs: Math.round(performance.now() - started), + ...state, + }), + ); + return item; + }; + const item = await loadPicked(file, 5000); + if (item) { + const same = await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + const source = viewer.dataSources.get(viewer.dataSources.length - 1); + return ( + JSON.stringify(window.__qaStyleSnapshot(source)) === + window.__qaUnsharedSnapshot + ); + }); + check('all 5,000 normalized graphics match their unshared control', same); + console.log('NORMALIZED CANDIDATE', JSON.stringify(await measure())); + await page.evaluate((id) => window.__gevGeoImport.remove(id), item.id); + } + if (supplied) { + const clockBefore = await page.evaluate(() => { + const v = window.__godsEyeView.viewer, + C = window.__CESIUM__; + return [ + C.JulianDate.toIso8601(v.clock.startTime), + C.JulianDate.toIso8601(v.clock.stopTime), + C.JulianDate.toIso8601(v.clock.currentTime), + v.clock.multiplier, + ]; + }); + const suppliedItem = await loadPicked(supplied, null); + const clockAfter = await page.evaluate(() => { + const v = window.__godsEyeView.viewer, + C = window.__CESIUM__; + return [ + C.JulianDate.toIso8601(v.clock.startTime), + C.JulianDate.toIso8601(v.clock.stopTime), + C.JulianDate.toIso8601(v.clock.currentTime), + v.clock.multiplier, + ]; + }); + check( + 'real dated KML leaves the shared clock unchanged', + JSON.stringify(clockBefore) === JSON.stringify(clockAfter), + ); + if (suppliedItem && process.argv.includes('--expect-local-pins')) { + const markers = await page.evaluate(() => { + const { viewer } = window.__godsEyeView, + C = window.__CESIUM__; + const source = viewer.dataSources.get(viewer.dataSources.length - 1); + const points = ( + source.importedEntities || source.entities.values + ).filter((e) => e.position && e.billboard); + const images = points.filter((e) => + e.billboard.image?.getValue(viewer.clock.currentTime), + ); + if (images.length) { + const at = C.Cartographic.fromCartesian( + images[0].position.getValue(viewer.clock.currentTime), + ); + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: C.Cartesian3.fromRadians( + at.longitude, + at.latitude, + 100000, + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + } + return { points: points.length, images: images.length }; + }); + check( + 'supplied point markers have drawable images', + markers.points > 0 && markers.images === markers.points, + JSON.stringify(markers), + ); + if (markers.points) { + const yellowPixels = async () => { + const shot = await page.screenshot({ + clip: { x: 620, y: 350, width: 200, height: 200 }, + type: 'png', + encoding: 'base64', + }); + return page.evaluate(async (base64) => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/png;base64,${base64}`)).blob(), + ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height), + context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const data = context.getImageData( + 0, + 0, + bitmap.width, + bitmap.height, + ).data; + let count = 0; + for (let i = 0; i < data.length; i += 4) + if (data[i] > 150 && data[i + 1] > 150 && data[i + 2] < 100) + count++; + return count; + }, shot); + }; + const setVisible = (visible) => + page.evaluate( + ({ id, visible }) => window.__gevGeoImport.setVisible(id, visible), + { id: suppliedItem.id, visible }, + ); + await setVisible(false); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const hidden = await yellowPixels(); + await setVisible(true); + let shown = hidden; + const deadline = performance.now() + 10000; + while (performance.now() < deadline && shown <= hidden + 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + shown = await yellowPixels(); + } + check( + 'real USGS markers paint when shown and disappear when hidden', + shown > hidden + 10, + JSON.stringify({ hidden, shown }), + ); + await page.screenshot({ + path: path.join(directory, 'real-kml-visible.png'), + }); + } + } + if (suppliedItem) + await page.evaluate( + (id) => window.__gevGeoImport.remove(id), + suppliedItem.id, + ); + } + check( + 'file-directed USGS icon/metadata requests never leave the page', + fileRequests.length === 0, + fileRequests.length, + ); + errors.push(...(await readRenderErrors(page))); + check( + 'no uncaught page or render errors', + errors.length === 0, + JSON.stringify(errors), + ); + check( + 'all imported sources are removed', + await page.evaluate(() => window.__gevGeoImport.list().length === 0), + ); + console.log('Fixture retained at', file); +} finally { + await browser.close(); +} +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-geo-import-regressions.mjs b/scripts/qa-geo-import-regressions.mjs new file mode 100644 index 000000000..ba5a040a2 --- /dev/null +++ b/scripts/qa-geo-import-regressions.mjs @@ -0,0 +1,564 @@ +#!/usr/bin/env node +/** Import regressions through the real UI controller and Cesium loaders. + * Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs + */ +import puppeteer from 'puppeteer'; +import * as Cesium from 'cesium'; +import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; + +const browser = await puppeteer.launch({ headless: true }); +const page = await browser.newPage(); +const meshBytes = Buffer.alloc(60); +[ + -2000, 2000, -2000, 2000, 2000, -2000, -2000, 2000, 2000, 2000, 2000, 2000, +].forEach((value, i) => meshBytes.writeFloatLE(value, i * 4)); +[0, 1, 2, 2, 1, 3].forEach((value, i) => + meshBytes.writeUInt16LE(value, 48 + i * 2), +); +const fixture = { + '/__qa_import_surface/tileset.json': { + asset: { version: '1.1' }, + geometricError: 0, + root: { + transform: Cesium.Matrix4.toArray( + Cesium.Transforms.eastNorthUpToFixedFrame( + Cesium.Cartesian3.fromDegrees(-122.5, 37.7), + ), + ), + boundingVolume: { box: [0, 0, 2000, 2000, 0, 0, 0, 2000, 0, 0, 0, 100] }, + geometricError: 0, + content: { uri: 'surface.gltf' }, + }, + }, + '/__qa_import_surface/surface.gltf': { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }], + }, + ], + materials: [{ doubleSided: true }], + buffers: [ + { + byteLength: 60, + uri: `data:application/octet-stream;base64,${meshBytes.toString('base64')}`, + }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: 48, target: 34962 }, + { buffer: 0, byteOffset: 48, byteLength: 12, target: 34963 }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 4, + type: 'VEC3', + min: [-2000, 2000, -2000], + max: [2000, 2000, 2000], + }, + { bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' }, + ], + }, +}; +await page.setRequestInterception(true); +page.on('request', (request) => { + const body = fixture[new URL(request.url()).pathname]; + if (body) + void request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + else void request.continue(); +}); +const errors = [], + requests = []; +page.on('pageerror', (e) => errors.push(e.message)); +page.on('console', (m) => { + if (/Ignoring clampToGround/.test(m.text())) errors.push(m.text()); +}); +await hookRenderErrors(page); +let failed = 0; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__gevGeoImport && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90000 }, + ); + await page.evaluate(async () => { + const { viewer, dataManager } = window.__godsEyeView, + C = window.__CESIUM__; + for (const id of dataManager.layers.keys()) + await dataManager.setEnabled(id, false); + viewer.imageryLayers.removeAll(); + viewer.terrainProvider = new C.EllipsoidTerrainProvider(); + viewer.clock.shouldAnimate = false; + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }); + page.on('request', (r) => { + if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url()); + }); + const results = await page.evaluate(async () => { + const { viewer } = window.__godsEyeView, + C = window.__CESIUM__, + tool = window.__gevGeoImport; + const results = [], + now = C.JulianDate.clone(viewer.clock.currentTime); + const check = (name, pass, detail = '') => + results.push({ name, pass: Boolean(pass), detail }); + const wrap = (body, attrs = '') => + `${body}`; + const point = '-122.5,37.7,0'; + const load = async (value, name) => { + const content = + typeof value === 'object' && !ArrayBuffer.isView(value) + ? JSON.stringify(value) + : value; + const [outcome] = await tool.importFiles([new File([content], name)]); + return { + outcome, + source: outcome.ok + ? viewer.dataSources.get(viewer.dataSources.length - 1) + : null, + }; + }; + const remove = (r) => { + if (r.outcome.ok) tool.remove(r.outcome.id); + }; + let r = await load( + { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'kartaview-owned', + properties: { + name: 'XYZ', + addProperty: 1, + 'marker-symbol': 'rail', + }, + geometry: { type: 'Point', coordinates: [-122.5, 37.7, 100] }, + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-122.5, 37.7, 10], + [-122.4, 37.7, 10], + [-122.4, 37.8, 10], + [-122.5, 37.7, 10], + ], + ], + }, + }, + ], + }, + 'xyz.geojson', + ); + const pin = (r.source?.importedEntities || r.source?.entities.values).find( + (e) => e.billboard, + ), + polygon = (r.source?.importedEntities || r.source?.entities.values).find( + (e) => e.polygon, + ); + check( + 'XYZ ground footprint and isolated entity IDs', + r.outcome.ok && + pin.id !== 'kartaview-owned' && + Math.abs( + C.Cartographic.fromCartesian(pin.position.getValue(now)).height, + ) < 0.01 && + polygon.polygon.perPositionHeight?.getValue(now) !== true, + ); + check( + 'ground pins have no persistent clamping callbacks', + pin?.billboard.heightReference?.getValue(now) === + C.HeightReference.NONE && + pin.billboard.disableDepthTestDistance.getValue(now) === Infinity, + ); + remove(r); + r = await load( + wrap( + 'ground-122.5 37.7 0-122.4 37.8 0' + + 'absoluteabsolute-122.5 37.7 10000-122.4 37.8 10000' + + 'line-122.5,37.7 -122.4,37.8', + ), + 'tracks.kml', + ); + const find = (name) => + (r.source?.importedEntities || r.source?.entities.values).find( + (e) => e.name === name, + ); + check( + 'ground tracks and untessellated authored lines clamp', + find('ground')?.polyline.clampToGround?.getValue(now) && + find('line')?.polyline.clampToGround?.getValue(now), + ); + check( + 'explicit track altitude and original vertices survive', + find('absolute')?.polyline.positions.getValue(now).length === 2 && + find('absolute') + .polyline.positions.getValue(now) + .every( + (p) => + Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01, + ), + ); + remove(r); + r = await load( + wrap( + ` #s ${point}`, + ), + 'local-style.kml', + ); + check('whitespace around a local style remains local', r.outcome.ok); + remove(r); + r = await load( + wrap( + `normalhttps://gev-qa-trap.invalid/style${point}`, + ), + 'incomplete-style.kml', + ); + check('removing external style does not hang the queue', r.outcome.ok); + remove(r); + r = await load( + wrap( + `0${point}`, + ), + 'hidden-folder.kml', + ); + check( + 'import show controls own previously hidden KML content', + r.outcome.ok && + (r.source.importedEntities || r.source.entities.values).every( + (e) => e.isShowing, + ), + ); + remove(r); + r = await load( + wrap( + 'ff00ff0037.837.7-122.4-122.5', + ), + 'colored.kml', + ); + check( + 'a colored overlay without an icon is drawable', + r.outcome.ok && + (r.source.importedEntities || r.source.entities.values).some( + (e) => e.rectangle, + ), + ); + remove(r); + const xml = wrap(`${point}`); + const utf16 = new Uint8Array(2 + xml.length * 2); + utf16.set([255, 254]); + for (let i = 0; i < xml.length; i++) { + utf16[2 + i * 2] = xml.charCodeAt(i) & 255; + utf16[3 + i * 2] = xml.charCodeAt(i) >> 8; + } + r = await load(utf16, 'utf16.kml'); + check('UTF-16 BOM is decoded', r.outcome.ok); + remove(r); + r = await load( + wrap( + `${point}`, + 'xmlns:gx="http://www.google.com/kml/ext/2.2" xsi:schemaLocation="test"', + ), + 'namespaces.kml', + ); + check( + 'ordinary duplicate and missing export namespaces are repaired', + r.outcome.ok, + ); + remove(r); + for (const [name, value] of [ + ['null geometry', { type: 'Feature', geometry: null }], + ['invalid coordinates', { type: 'Point', coordinates: ['bad', 37] }], + [ + 'missing feature type', + { + type: 'FeatureCollection', + features: [{ geometry: { type: 'Point', coordinates: [0, 0] } }], + }, + ], + [ + 'nested unsupported geometry', + { type: 'Feature', geometry: { type: 'Topology', arcs: [] } }, + ], + ]) { + r = await load(value, 'refused.geojson'); + check(`${name} is refused without stopping rendering`, !r.outcome.ok); + remove(r); + } + r = await load( + wrap( + 'NaN,37,0', + ), + 'invalid.kml', + ); + check('non-finite KML coordinates are refused', !r.outcome.ok); + remove(r); + r = await load( + { + type: 'LineString', + coordinates: [ + [-122.6, 37.7], + [-122.4, 37.7], + ], + }, + 'portrait.geojson', + ); + await new Promise((resolve) => setTimeout(resolve, 2300)); + const positions = (r.source?.importedEntities || + r.source?.entities.values)[0].polyline.positions.getValue(now); + const screen = positions?.map((p) => + C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p), + ); + check( + 'portrait flight includes both route endpoints', + screen?.every( + (p) => p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight, + ), + screen?.map((p) => p && [p.x, p.y]), + ); + remove(r); + r = await load( + { + type: 'MultiPoint', + coordinates: [ + [-74, 40.7], + [139.7, 35.7], + ], + }, + 'intercontinental.geojson', + ); + await new Promise((resolve) => setTimeout(resolve, 2300)); + const intercontinental = ( + r.source?.importedEntities || r.source?.entities.values + ) + .filter((e) => e.position) + .map((e) => e.position.getValue(now)); + const occluder = new C.EllipsoidalOccluder( + C.Ellipsoid.WGS84, + viewer.camera.positionWC, + ); + check( + 'intercontinental points remain in frame and in front of the horizon', + intercontinental?.every((p) => { + const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p); + return ( + occluder.isPointVisible(p) && + s && + s.x > 0 && + s.x < innerWidth && + s.y > 0 && + s.y < innerHeight + ); + }), + ); + remove(r); + r = await load( + wrap( + 'absolute14.9,52.37,3000 5.12,52.09,30005.12,52.09', + ), + 'wall.kml', + ); + await new Promise((resolve) => setTimeout(resolve, 2300)); + check( + 'a wall contributes both endpoints to the flight', + r.outcome.ok && + (r.source.importedEntities || r.source.entities.values) + .find((e) => e.wall) + ?.wall.positions.getValue(now) + .every((p) => { + const s = C.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + p, + ); + return ( + s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight + ); + }), + ); + remove(r); + r = await load( + { + type: 'MultiPoint', + coordinates: [ + [0, 0], + [180, 0], + ], + }, + 'antipodal.geojson', + ); + await new Promise((resolve) => setTimeout(resolve, 1800)); + check( + 'symmetric global extents keep a finite camera', + r.outcome.ok && + Number.isFinite(viewer.camera.positionCartographic.height), + ); + remove(r); + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + const mesh = await C.Cesium3DTileset.fromUrl( + `${location.origin}/__qa_import_surface/tileset.json`, + ); + viewer.scene.primitives.add(mesh); + // This fixture installs a mesh outside the app's map-source lifecycle. + // Let the new camera/tiles reach a rendered frame before testing sampling. + await new Promise((resolve) => { + let frames = 0; + const remove = viewer.scene.postRender.addEventListener(() => { + if (++frames >= 2) { + remove(); + resolve(); + } else viewer.scene.requestRender(); + }); + viewer.scene.requestRender(); + }); + const fixtureDeadline = performance.now() + 5000; + while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) { + viewer.scene.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const globeShown = viewer.scene.globe.show; + viewer.scene.globe.show = false; + + r = await load( + { type: 'Point', coordinates: [-122.5, 37.7, 0] }, + 'mesh-point.geojson', + ); + const meshPoint = (r.source?.importedEntities || r.source?.entities.values) + .find((e) => e.billboard) + ?.position.getValue(now); + const meshHeight = + meshPoint && C.Cartographic.fromCartesian(meshPoint).height; + check( + 'native 3D Tiles ground sampling keeps pins on the rendered mesh', + r.outcome.ok && Math.abs(meshHeight - 2000) < 2, + { + meshHeight, + tilesLoaded: mesh.tilesLoaded, + globeShown: viewer.scene.globe.show, + }, + ); + tool.flyTo(r.outcome.id); + await new Promise((resolve) => setTimeout(resolve, 2300)); + const meshScreen = + meshPoint && + C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint); + check( + 'a high-surface arrival frames the elevated pin', + meshScreen && + Math.abs(meshScreen.x - innerWidth / 2) < 20 && + Math.abs(meshScreen.y - innerHeight / 2) < 20, + { + screen: meshScreen && [meshScreen.x, meshScreen.y], + camera: { + height: viewer.camera.positionCartographic.height, + pitch: viewer.camera.pitch, + }, + meshHeight, + }, + ); + const groundSource = r.source; + const elevated = await load( + wrap( + 'absolute-122.5,37.7,5000', + ), + 'elevated-pin.kml', + ); + await new Promise((resolve) => setTimeout(resolve, 2600)); + check( + 'a higher imported pin never becomes the sampled ground', + elevated.outcome.ok && + Math.abs( + C.Cartographic.fromCartesian( + groundSource.importedBillboards.get(0).position, + ).height - 2000, + ) < 2, + ); + remove(elevated); + remove(r); + viewer.scene.primitives.remove(mesh); + viewer.scene.globe.show = globeShown; + const before = tool.list().length; + const transfer = new DataTransfer(); + transfer.items.add( + new File( + [JSON.stringify({ type: 'Point', coordinates: [-122.5, 37.7] })], + 'panel-drop.geojson', + ), + ); + const event = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + }); + document.getElementById('geo-import-button').dispatchEvent(event); + for (let i = 0; i < 100 && tool.list().length === before; i++) + await new Promise((resolve) => setTimeout(resolve, 20)); + check( + 'a panel drop imports and prevents the browser default', + event.defaultPrevented && tool.list().length === before + 1, + ); + for (const file of tool.list()) tool.remove(file.id); + check( + 'the shared clock stays exactly unchanged', + C.JulianDate.equals(now, viewer.clock.currentTime), + ); + check('no file sources remain', tool.list().length === 0); + return results; + }); + for (const r of results) { + console.log( + `[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`, + JSON.stringify(r.detail), + ); + if (!r.pass) failed++; + } + const renderErrors = await readRenderErrors(page); + if (errors.length || renderErrors.length) { + failed++; + console.log( + '[FAIL] browser/render errors', + JSON.stringify({ errors, renderErrors }), + ); + } + // Terrain belongs to the base map; imported files may sample that provider. + const unexpected = requests.filter( + (url) => + new URL(url).pathname !== '/logo.svg' && + !new URL(url).pathname.startsWith('/__qa_import_surface/') && + !/\/api\/|\.terrain(?:\?|$)|reearth|cesium|arcgis|openstreetmap|\.pbf(?:\?|$)/i.test( + url, + ), + ); + console.log( + `[${unexpected.length ? 'FAIL' : 'PASS'}] no unexpected file-directed requests`, + JSON.stringify(unexpected), + ); + if (unexpected.length) failed++; +} finally { + await browser.close(); +} +process.exitCode = failed ? 1 : 0; diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs new file mode 100644 index 000000000..074eb8fbd --- /dev/null +++ b/scripts/qa-geo-import.mjs @@ -0,0 +1,594 @@ +#!/usr/bin/env node +/** + * Rendered acceptance for DISPLAY ▸ Import: real files through the real file + * input and a real drop, parsed by Cesium in the page. + * + * Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX + * each become one row and one data source; the imported shape actually paints + * on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override + * it) and stops painting when hidden; no import makes the page request + * anything named in it (network link, remote icon, remote style, palette + * icon, balloon or extended-data HTML, maki marker) or anything outside the + * page for it; no entity keeps a description, label or properties; dated KML + * and GPX leave the app's clock alone and still draw now; a GPX track keeps + * its two segments apart; keyboard focus survives hide/show; a dropped file + * imports like a picked one; a file that is not geodata is refused with a + * reason and leaves nothing behind; Remove gives every data source back; no + * page errors. + * + * Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`. + * QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; + +const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; +const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import'; +const HEADFUL = process.env.QA_HEADFUL === '1'; +// Golden Gate Park: a big, flat, green rectangle the test polygon can cover. +const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 }; +const TRAP_HOST = 'gev-qa-trap.invalid'; + +/* ── fixtures ──────────────────────────────────────────────────────────── */ + +const ring = [ + [-122.511, 37.7645], + [-122.4545, 37.7645], + [-122.4545, 37.7745], + [-122.511, 37.7745], + [-122.511, 37.7645], +]; +const geojson = JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + // A file's own simplestyle and HTML must not survive the import. + properties: { + name: 'QA park', + stroke: '#ff0000', + fill: '#ff0000', + 'fill-opacity': 1, + description: ``, + }, + geometry: { type: 'Polygon', coordinates: [ring.map((p) => [...p, 0])] }, + }, + { + type: 'Feature', + properties: { name: 'QA pin', 'marker-symbol': 'rail' }, + geometry: { type: 'Point', coordinates: [-122.4862, 37.7694, 0] }, + }, + ], +}); +const kml = (iconHref) => ` + + + + + https://${TRAP_HOST}/link.kml + QA KML pin#s + ]]> + 2019-01-01T00:00:00Z2019-01-02T00:00:00Z + ]]> + -122.47,37.77,0 + QA palette pin#palette + -122.48,37.771,0 + QA moving + 2019-01-01T00:00:00Z2019-01-01T01:00:00Z + -122.49 37.767 0-122.475 37.772 0 + QA split tracks + -122.50 37.767 0-122.49 37.768 0 + -122.47 37.771 0-122.46 37.772 0 + + QA remote stylehttps://${TRAP_HOST}/styles.kml#x + -122.50,37.766 -122.46,37.772 + https://${TRAP_HOST}/overlay.png + 37.7737.76-122.46-122.47 + +`; +const gpx = ` + + QA waypoint + QA track<img src="https://${TRAP_HOST}/gpx.png"> + + + + +`; +// 1×1 PNG. +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', +); +/** Stored (uncompressed) zip — enough for a KMZ fixture. */ +function storedZip(files) { + const parts = []; + const central = []; + let offset = 0; + for (const [name, data] of files) { + const n = Buffer.from(name); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(n.length, 26); + const c = Buffer.alloc(46); + c.writeUInt32LE(0x02014b50, 0); + c.writeUInt32LE(data.length, 20); + c.writeUInt32LE(data.length, 24); + c.writeUInt16LE(n.length, 28); + c.writeUInt32LE(offset, 42); + parts.push(local, n, data); + central.push(c, n); + offset += 30 + n.length + data.length; + } + const dir = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(dir.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...parts, dir, end]); +} +const kmzDoc = kml('files/pin.png').replace( + `https://${TRAP_HOST}/overlay.png`, + 'files/overlay.png', +); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-import-')); +const fixture = (name, data) => { + const file = path.join(dir, name); + fs.writeFileSync(file, data); + return file; +}; +const FILES = [ + fixture('park.geojson', geojson), + fixture('remote-bits.kml', kml(`https://${TRAP_HOST}/icon.png`)), + fixture( + 'packed.kmz', + storedZip([ + ['doc.kml', Buffer.from(kmzDoc)], + ['files/pin.png', PNG], + ['files/overlay.png', PNG], + ]), + ), + fixture('walk.gpx', gpx), +]; + +/* ── harness ───────────────────────────────────────────────────────────── */ + +const browser = await puppeteer.launch({ + headless: !HEADFUL, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +await hookRenderErrors(page); +const errors = []; +const trapped = []; +page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +page.on('console', (message) => { + if (/Ignoring clampToGround/.test(message.text())) + errors.push(message.text()); +}); +// Anything a file names, and the icon assets Cesium would load on a file's +// behalf (KML palette icons from maps.google.com, GeoJSON maki markers). +const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/']; +page.on('request', (request) => { + if (FILE_FETCH.some((needle) => request.url().includes(needle))) + trapped.push(request.url()); +}); +let failures = 0; +function check(name, passed, detail = '') { + console.log( + `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`, + ); + if (!passed) failures++; +} +fs.mkdirSync(SHOT_DIR, { recursive: true }); +const shot = (name) => + page.screenshot({ + path: path.join(SHOT_DIR, `${name}.jpg`), + type: 'jpeg', + quality: 82, + }); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const CLIP = { x: 380, y: 240, width: 480, height: 320 }; + +/** Pixels in the clip close to the first import color (#39d0ff). */ +async function cyanPixels() { + await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender()); + await wait(600); + const base64 = await page.screenshot({ + clip: CLIP, + type: 'png', + encoding: 'base64', + }); + return page.evaluate(async (b64) => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/png;base64,${b64}`)).blob(), + ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height); + let n = 0; + for (let i = 0; i < data.length; i += 4) { + const [r, g, b] = [data[i], data[i + 1], data[i + 2]]; + if (b > 45 && g > 30 && r < 100 && b - r > 30) n += 1; + } + return n; + }, base64); +} +const state = () => + page.evaluate(() => ({ + files: window.__gevGeoImport.list(), + dataSources: window.__godsEyeView.viewer.dataSources.length, + rows: document.querySelectorAll('#geo-import-list .geo-import-row').length, + hint: document.getElementById('geo-import-hint')?.textContent || '', + })); + +try { + await page.setViewport({ width: 1280, height: 860 }); + await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__gevGeoImport && + window.__godsEyeView?.viewer && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90_000 }, + ); + await page.evaluate(async (view) => { + const viewer = window.__godsEyeView.viewer; + for (const id of window.__godsEyeView.dataManager.layers.keys()) + await window.__godsEyeView.dataManager.setEnabled(id, false); + viewer.imageryLayers.removeAll(); + viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider(); + viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK; + viewer.scene.globe.enableLighting = false; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: window.__CESIUM__.Cartesian3.fromDegrees( + view.lon, + view.lat, + view.height, + ), + orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, + }); + viewer.scene.requestRender(); + }, VIEW); + await wait(4000); + await page.evaluate(() => { + const rail = document.getElementById('pp-toggles'); + if (rail?.classList.contains('collapsed')) + document + .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]') + ?.click(); + }); + await page.waitForFunction( + () => + document.getElementById('geo-import-button')?.getBoundingClientRect() + .width > 0, + { timeout: 10_000 }, + ); + const before = await state(); + check( + 'Import is on the DISPLAY rail with an empty, hidden list', + before.rows === 0 && + !(await page.evaluate(() => + document + .getElementById('geo-import-list') + .classList.contains('visible'), + )), + ); + const cyanBefore = await cyanPixels(); + await page.evaluate(() => { + window.__godsEyeView.viewer.clock.shouldAnimate = false; + }); + const clock = () => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.clock; + const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0); + return { + start: iso(c.startTime), + stop: iso(c.stopTime), + range: c.clockRange, + step: c.clockStep, + multiplier: c.multiplier, + animate: c.shouldAnimate, + // Wall-clock drift aside, the current time must not jump to 2018/2019. + currentTime: window.__CESIUM__.JulianDate.toIso8601(c.currentTime), + }; + }); + const clockBefore = await clock(); + const sourcesBefore = before.dataSources; + + // Picked through the real . + const input = await page.$('#geo-import-input'); + await input.uploadFile(...FILES); + const imported = await page + .waitForFunction(() => window.__gevGeoImport.list().length === 4, { + timeout: 60_000, + }) + .then( + () => true, + () => false, + ); + const after = await state(); + check( + 'four picked files become four rows and four data sources', + imported && + after.rows === 4 && + after.dataSources === before.dataSources + 4, + JSON.stringify( + after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`), + ), + ); + check( + 'formats are recognized', + after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx', + ); + check( + 'the KML and KMZ had their outside references removed', + after.files[1].removed > 0 && after.files[2].removed > 0, + `removed ${after.files[1].removed}/${after.files[2].removed}`, + ); + check( + 'the hint says so', + /Added 4 files\..*left out/.test(after.hint), + after.hint, + ); + const clockAfter = await clock(); + check( + "dated KML and GPX leave the app's clock alone", + JSON.stringify(clockAfter) === JSON.stringify(clockBefore) && + (await page.evaluate( + () => + window.__godsEyeView.viewer.clockTrackedDataSource?.clock === + undefined, + )), + `${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`, + ); + const inside = await page.evaluate((from) => { + const viewer = window.__godsEyeView.viewer; + const now = viewer.clock.currentTime; + const sources = []; + for (let i = from; i < viewer.dataSources.length; i++) + sources.push(viewer.dataSources.get(i)); + const entities = sources.flatMap( + (ds) => ds.importedEntities || ds.entities.values, + ); + const named = (name) => entities.find((e) => e.name === name); + const gpx = sources[3].importedEntities || sources[3].entities.values; + const kmz = sources[2].importedEntities || sources[2].entities.values; + return { + sources: sources.length, + clocks: sources.filter((ds) => ds.clock !== undefined).length, + described: entities.filter((e) => e.description !== undefined).length, + labelled: entities.filter((e) => e.label !== undefined).length, + withProperties: entities.filter((e) => e.properties !== undefined).length, + withKml: entities.filter((e) => e.kml !== undefined).length, + hidden: entities.filter((e) => !e.isAvailable(now)).length, + datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)), + fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) => + named(name)?.billboard?.image?.getValue(now), + ).length, + movingIsLine: Boolean( + named('QA moving')?.polyline && !named('QA moving')?.position, + ), + movingVertices: + named('QA moving')?.polyline?.positions?.getValue(now)?.length, + splitTracks: (sources[1].importedEntities || sources[1].entities.values) + .filter((e) => e.parent?.name === 'QA split tracks' && e.polyline) + .map((e) => e.polyline.positions.getValue(now).length), + gpxLines: gpx.filter((e) => e.polyline).length, + gpxPoints: gpx.filter((e) => e.billboard || e.point).length, + kmzLocalImage: kmz.some((e) => + String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'), + ), + kmzOverlay: kmz.some((e) => e.rectangle), + groundedLines: sources + .slice(1, 3) + .every((ds) => + (ds.importedEntities || ds.entities.values) + .filter((e) => e.polyline) + .every((e) => e.polyline.clampToGround?.getValue(now)), + ), + xyzPolygon: + (sources[0].importedEntities || sources[0].entities.values) + .find((e) => e.polygon) + ?.polygon.perPositionHeight?.getValue(now) !== true, + }; + }, sourcesBefore); + check( + 'no imported entity keeps a description, label, properties or KML data', + inside.sources === 4 && + inside.described + + inside.labelled + + inside.withProperties + + inside.withKml === + 0, + JSON.stringify(inside), + ); + check( + 'no imported source has a clock, and dated features draw now', + inside.clocks === 0 && + inside.hidden === 0 && + inside.datedPinNow && + inside.movingIsLine && + inside.movingVertices === 2, + JSON.stringify(inside), + ); + check( + 'blocked KML icon sources use drawable local fallback pins', + inside.fallbackPins === 2, + JSON.stringify({ fallbackPins: inside.fallbackPins }), + ); + check( + 'KML MultiTrack keeps its segments and original vertex count', + inside.splitTracks.join() === '2,2', + JSON.stringify(inside.splitTracks), + ); + check( + 'the GPX track keeps its two segments apart', + inside.gpxLines === 2 && inside.gpxPoints === 1, + JSON.stringify(inside), + ); + check( + 'KMZ retains its own packed image and overlay', + inside.kmzLocalImage && inside.kmzOverlay, + ); + check( + 'XYZ polygons and KML/KMZ lines use ground geometry', + inside.xyzPolygon && inside.groundedLines, + ); + await page.evaluate((from) => { + for (const entity of window.__godsEyeView.viewer.dataSources.get(from) + .entities.values) + if (entity.billboard) entity.show = false; + }, sourcesBefore); + await page.evaluate((view) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: window.__CESIUM__.Cartesian3.fromDegrees( + view.lon, + view.lat, + view.height, + ), + orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, + }); + }, VIEW); + await wait(2500); + const cyanShown = await cyanPixels(); + await shot('imported'); + check( + 'the imported polygon paints on the globe', + cyanShown > cyanBefore * 4 && cyanShown > cyanBefore + 5000, + `${cyanBefore} → ${cyanShown}`, + ); + + const parkId = after.files[0].id; + await page.click( + `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`, + ); + const cyanHidden = await cyanPixels(); + check( + 'hiding it stops it painting', + cyanHidden < cyanShown / 3, + `${cyanShown} → ${cyanHidden}`, + ); + await page.click( + `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`, + ); + check( + 'showing it again paints it again', + (await cyanPixels()) > cyanHidden * 4, + ); + + // Keyboard: focus stays on the same control when the list is rebuilt. + const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`; + await page.focus(toggleSelector); + await page.keyboard.press('Enter'); + const focusAfterHide = await page.evaluate( + (sel) => document.activeElement === document.querySelector(sel), + toggleSelector, + ); + await page.keyboard.press('Enter'); + const focusAfterShow = await page.evaluate( + (sel) => + document.activeElement === document.querySelector(sel) && + document.activeElement.getAttribute('aria-pressed') === 'true', + toggleSelector, + ); + check( + 'keyboard focus stays on the toggle through hide and show', + focusAfterHide && focusAfterShow, + ); + + // Dropped onto the globe, as a person drags from their file manager. + await page.evaluate((text) => { + const container = window.__godsEyeView.viewer.container; + const transfer = new DataTransfer(); + transfer.items.add( + new File([text], 'dropped.geojson', { type: 'application/geo+json' }), + ); + for (const type of ['dragenter', 'dragover', 'drop']) + container.dispatchEvent( + new DragEvent(type, { + dataTransfer: transfer, + bubbles: true, + cancelable: true, + }), + ); + }, geojson); + const dropped = await page + .waitForFunction(() => window.__gevGeoImport.list().length === 5, { + timeout: 20_000, + }) + .then( + () => true, + () => false, + ); + check('a file dropped on the globe imports', dropped); + + // Not geodata: refused with a reason, nothing added. + await page.evaluate(() => { + const transfer = new DataTransfer(); + transfer.items.add( + new File(['just some notes'], 'notes.txt', { type: 'text/plain' }), + ); + window.__godsEyeView.viewer.container.dispatchEvent( + new DragEvent('drop', { + dataTransfer: transfer, + bubbles: true, + cancelable: true, + }), + ); + }); + await wait(800); + const refused = await state(); + check( + 'a file that is not geodata is refused with a reason', + refused.files.length === 5 && + /notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint), + refused.hint, + ); + + // Remove every file through its row button. + for (const { id } of refused.files) + await page.click( + `#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`, + ); + const cleared = await state(); + check( + 'Remove gives every data source back', + cleared.rows === 0 && cleared.dataSources === before.dataSources, + `${cleared.dataSources} vs ${before.dataSources}`, + ); + await wait(1500); + check( + 'nothing named inside a file, and no icon asset for one, was ever requested', + trapped.length === 0, + trapped.join(' '), + ); + errors.push(...(await readRenderErrors(page))); + check('no page or rendering errors', errors.length === 0, errors.join(' | ')); +} catch (error) { + check('harness ran to completion', false, error?.stack || String(error)); +} finally { + await browser.close(); + fs.rmSync(dir, { recursive: true, force: true }); +} +console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed'); +process.exit(failures ? 1 : 0); diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs new file mode 100644 index 000000000..83dc8ed97 --- /dev/null +++ b/scripts/qa-ghostmaps.mjs @@ -0,0 +1,645 @@ +#!/usr/bin/env node +/** + * qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer + * (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from + * github.com/s2underground/GhostMaps. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * + * (i) LIVE — enabling the layer loads the default products (Common + * Intelligence Picture and the 30-day kinetic pull) from the live + * repository through /api/ghostmaps, one data source per product, + * matching the proxy's catalog commit; the row shows one chip per + * catalog product with the defaults active, and the page holds an + * open event stream to /api/ghostmaps/events. + * (ii) PICK — a Common Intelligence Picture marker at the screen centre + * picks as this layer's entity; clicking it paints its report card + * and clicking empty ground clears it. + * (iii) PUSH (synthetic) — the event stream is replaced in the page and a + * catalog naming a new edition of one product is pushed: only that + * product reloads (a new data source replaces the old one, no + * duplicates), the other keeps its data source. A catalog without a + * product removes it. + * (iv) CHIPS — clicking an active product chip removes that product, + * clicking it again restores it; the choice survives a reload. + * (v) DISABLE — every GhostMaps data source leaves the viewer. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips + * fit the panel width with no horizontal overflow. + * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: + * seen from over the Indian Ocean no pin clearly behind the planet is + * shown; back over North America the near-side pins show again. + * + * Screenshots go to qa-shots/ghostmaps-*.png (gitignored). + * + * Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173 + * Optional: --timeout-ms 180000 for a slow boot, --shots . + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000')); +const SHOTS_DIR = path.resolve( + getOpt('--shots', path.join(__dirname, '..', 'qa-shots')), +); +const LAYER_ID = 's2-ghostmaps'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function boot(page) { + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); +} + +/** GhostMaps data sources on the viewer: name → entity count. */ +function ghostSources(prefix) { + const { dataSources } = window.__godsEyeView.viewer; + const out = []; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (ds.name?.startsWith(prefix)) + out.push({ name: ds.name, count: ds.entities.values.length }); + } + return out; +} + +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + +async function enableAndSettle(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel?.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]')?.click(); + return window.__godsEyeView.dataManager.setEnabled(id, true); + }, LAYER_ID); + await page.waitForFunction( + (id) => { + const stats = window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats(); + return !stats.loading && stats.count > 0; + }, + { timeout: TIMEOUT_MS, polling: 500 }, + LAYER_ID, + ); +} + +const stats = (page) => + page.evaluate( + (id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(), + LAYER_ID, + ); +const sources = (page) => + page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`); + +async function showRow(page) { + await page.evaluate((id) => { + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(400); +} + +async function main() { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const browser = await puppeteer.launch({ + headless: !HEADFUL, + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, + }); + try { + // ---- (i) LIVE --------------------------------------------------------- + const page = await browser.newPage(); + // Start clean once; a reload must keep what the page stored. + await page.evaluateOnNewDocument(() => { + try { + if (sessionStorage.getItem('qa-ghostmaps-clean')) return; + localStorage.clear(); + sessionStorage.setItem('qa-ghostmaps-clean', '1'); + } catch { + /* sandboxed frames have no storage */ + } + }); + page.on('pageerror', (error) => + console.log(` [page error] ${error.message}`), + ); + await boot(page); + const catalog = await page.evaluate(() => + fetch('/api/ghostmaps/catalog').then((r) => r.json()), + ); + record( + 'LIVE: proxy catalog names a commit and the default products', + /^[0-9a-f]{40}$/.test(catalog.commit || '') && + ['cip', 'kinetic-30d'].every((id) => + catalog.products.some((p) => p.id === id && p.defaultOn), + ), + `${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`, + ); + const t0 = Date.now(); + await enableAndSettle(page); + const live = await stats(page); + const liveSources = await sources(page); + record( + 'LIVE: default products load as one data source each', + liveSources.length === 2 && + liveSources.every((s) => s.count > 0) && + live.products.sort().join() === 'cip,kinetic-30d', + `${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`, + ); + record( + 'LIVE: layer reports the proxy commit and no error', + live.commit === catalog.commit && !live.error, + `commit ${live.commit?.slice(0, 7)}, error ${live.error}`, + ); + await showRow(page); + const chips = await page.evaluate( + (id) => + [ + ...document.querySelectorAll( + `[data-layer-id="${id}"] .data-toggle-chip`, + ), + ].map((chip) => ({ + id: chip.dataset.chipId, + active: chip.getAttribute('aria-pressed') === 'true', + })), + LAYER_ID, + ); + record( + 'LIVE: one chip per product, defaults pressed', + chips.length === catalog.products.length && + chips.every( + (chip) => + chip.active === + catalog.products.find((p) => p.id === chip.id)?.defaultOn, + ), + chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '), + ); + const stream = await page + .waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers.get(id).module.getStats() + .stream === 'open', + { timeout: 15000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record('LIVE: the /api/ghostmaps/events stream is open', stream); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); + + // ---- (ii) PICK ---------------------------------------------------------- + const target = await page.evaluate((id) => { + const gev = window.__godsEyeView; + const ds = [...Array(gev.viewer.dataSources.length).keys()] + .map((i) => gev.viewer.dataSources.get(i)) + .find((item) => item.name === `${id}:cip`); + const entity = ds.entities.values.find((e) => e.billboard); + const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic( + entity.position.getValue(gev.viewer.clock.currentTime), + ); + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: carto.longitude, + latitude: carto.latitude, + height: 40000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + return entity.id; + }, LAYER_ID); + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 30000 }, + ) + .catch(() => {}); + // The horizon cull reveals the pin on the frame after the camera moves. + await page + .waitForFunction( + () => { + const v = window.__godsEyeView.viewer; + v.scene.requestRender?.(); + const c = v.scene.canvas; + return v.scene + .drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8) + .some((p) => + String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'), + ); + }, + { timeout: 15000, polling: 500 }, + ) + .catch(() => {}); + const probe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const c = v.scene.canvas; + const box = c.getBoundingClientRect(); + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const picked = v.scene.drillPick(centre, 8); + // Empty ground: the first point below the marker (the card is drawn + // above it) that picks nothing at all; markers can be dense in a city. + let empty = null; + for (let r = 120; r < 420 && !empty; r += 60) + for (const a of [2, 1, 3, 0, 4]) { + const p = { + x: centre.x + r * Math.cos((a * Math.PI) / 4), + y: centre.y + r * Math.sin((a * Math.PI) / 4), + }; + if (!v.scene.pick(p)) { + empty = p; + break; + } + } + empty ||= { x: centre.x + 400, y: centre.y + 300 }; + return { + ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')), + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + }; + }); + record( + 'PICK: the centred marker picks as a GhostMaps entity', + probe.ids.some((id) => id.startsWith('ghostmaps:cip:')), + `${target} → [${probe.ids.slice(0, 3).join(', ')}]`, + ); + const before = await page.evaluate(paintedOverlayPixels); + await page.mouse.click(probe.centre.x, probe.centre.y); + const painted = await page + .waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, { + timeout: 10000, + polling: 250, + }) + .then( + () => true, + () => false, + ); + const after = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: clicking the marker paints its report card', + painted, + `${before} → ${after} pixels`, + ); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') }); + const cardState = () => + page.evaluate((id) => { + const d = window.__gevWorldOverlay?.getDiagnostics?.(); + return { + entries: d?.entriesBySource?.[id] ?? null, + painted: d?.paintedBySource?.[id] ?? null, + }; + }, LAYER_ID); + const selectedState = await cardState(); + record( + 'PICK: the overlay host holds and paints exactly one card', + selectedState.entries === 1 && selectedState.painted === 1, + JSON.stringify(selectedState), + ); + await page.mouse.click(probe.empty.x, probe.empty.y); + // The overlay canvas is `desynchronized`, so headless read-back can + // return a stale buffer; the host's own model is the authority here. + const cleared = await page + .waitForFunction( + (id) => { + const d = window.__gevWorldOverlay.getDiagnostics(); + return ( + d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0) + ); + }, + { timeout: 10000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record( + 'PICK: clicking empty ground clears the card', + cleared, + JSON.stringify(await cardState()), + ); + + // ---- (vii) FAR SIDE --------------------------------------------------- + // Pins skip the depth test; with Google 3D tiles nothing else hides the + // far side. A pin clearly behind the planet (sphere test with margin) must + // be hidden, and pins facing the camera must still show. + const horizonCounts = (lon, lat) => + page.evaluate( + async (id, lo, la) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: + gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: 18_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 1200)); + const c = gev.viewer.camera.positionWC; + const norm = (v) => Math.hypot(v.x, v.y, v.z); + let far = 0; + let nearShown = 0; + for (let i = 0; i < gev.viewer.dataSources.length; i++) { + const ds = gev.viewer.dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + for (const e of ds.entities.values) { + if (!e.billboard) continue; + const p = e.position.getValue(gev.viewer.clock.currentTime); + // Angle from the camera's nadir to the pin, against the + // geometric horizon angle; the cull's 1 km margin shows pins up + // to about 1° past it, so "clearly behind" is beyond 2°. + const angle = Math.acos( + (p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)), + ); + const horizon = Math.acos(norm(p) / norm(c)); + const margin = (2 * Math.PI) / 180; + if (e.show && angle > horizon + margin) far++; + if (e.show && angle < horizon - margin) nearShown++; + } + } + return { far, nearShown }; + }, + LAYER_ID, + lon, + lat, + ); + const fromIndianOcean = await horizonCounts(80, -30); + record( + 'FAR SIDE: pins behind the planet are hidden', + fromIndianOcean.far === 0, + JSON.stringify(fromIndianOcean), + ); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'), + }); + const fromAmerica = await horizonCounts(-98, 39); + record( + 'FAR SIDE: pins facing the camera show again', + fromAmerica.far === 0 && fromAmerica.nearShown > 100, + JSON.stringify(fromAmerica), + ); + + // ---- (iv) CHIPS ------------------------------------------------------- + const clickChip = async (id) => { + // Row controls render on the next frame after the layer settles. + await page.waitForSelector( + `[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`, + { timeout: 15000 }, + ); + return page.evaluate( + (layer, chip) => + document + .querySelector( + `[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`, + ) + ?.click(), + LAYER_ID, + id, + ); + }; + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + !window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 15000 }, + LAYER_ID, + ); + const without = await sources(page); + record( + 'CHIPS: turning a product off removes only its data source', + without.length === 1 && without[0].name === `${LAYER_ID}:cip`, + without.map((s) => s.name).join(', '), + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: TIMEOUT_MS, + }); + await sleep(3000); + await enableAndSettle(page); + const kept = await stats(page); + record( + 'CHIPS: the product choice survives a reload', + kept.products.join() === 'cip', + kept.products.join(', '), + ); + await showRow(page); + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 30000 }, + LAYER_ID, + ); + record( + 'CHIPS: turning it back on restores it', + (await sources(page)).length === 2, + ); + + // ---- (v) DISABLE ------------------------------------------------------ + await page.evaluate( + (id) => window.__godsEyeView.dataManager.setEnabled(id, false), + LAYER_ID, + ); + await sleep(500); + record( + 'DISABLE: every GhostMaps data source leaves the viewer', + (await sources(page)).length === 0, + ); + await page.close(); + + // ---- (iii) PUSH (synthetic) ---------------------------------------------- + const push = await browser.newPage(); + await push.evaluateOnNewDocument(() => { + localStorage.clear(); + // A controllable stand-in for the real stream. + class QaEventSource { + constructor(url) { + this.url = url; + this.listeners = new Set(); + (window.__qaStreams ||= []).push(this); + } + addEventListener(type, listener) { + if (type === 'catalog') this.listeners.add(listener); + } + removeEventListener(type, listener) { + this.listeners.delete(listener); + } + close() { + this.closed = true; + } + emit(catalog) { + for (const listener of this.listeners) + listener({ data: JSON.stringify(catalog) }); + } + } + window.EventSource = QaEventSource; + }); + await boot(push); + await enableAndSettle(push); + const identity = () => + push.evaluate((id) => { + const { dataSources } = window.__godsEyeView.viewer; + window.__qaIds ||= new WeakMap(); + window.__qaNext ||= 1; + const out = {}; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + if (!window.__qaIds.has(ds)) + window.__qaIds.set(ds, window.__qaNext++); + out[ds.name] = window.__qaIds.get(ds); + } + return out; + }, LAYER_ID); + const first = await identity(); + const pushed = Date.now(); + await push.evaluate(async (id) => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d'); + kinetic.version = 'f'.repeat(40); + catalog.message = 'QA push'; + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }, LAYER_ID); + await push.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id).module; + return ( + !layer.getStats().loading && + layer.getRowControls().info.includes('QA push') + ); + }, + { timeout: 20000, polling: 100 }, + LAYER_ID, + ); + await sleep(500); + const second = await identity(); + record( + 'PUSH: a new edition reloads only that product', + Object.keys(second).length === 2 && + second[`${LAYER_ID}:kinetic-30d`] !== + first[`${LAYER_ID}:kinetic-30d`] && + second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`], + `${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`, + ); + await push.evaluate(async () => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d'); + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }); + await sleep(1000); + const third = await identity(); + record( + 'PUSH: a product leaving the catalog leaves the globe', + Object.keys(third).join() === `${LAYER_ID}:cip`, + Object.keys(third).join(', '), + ); + await push.close(); + + // ---- (vi) VIEWPORT ---------------------------------------------------- + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + await phone.evaluateOnNewDocument(() => localStorage.clear()); + await boot(phone); + await enableAndSettle(phone); + await showRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const panel = document.getElementById('data-panel'); + const controls = row?.querySelector('.data-toggle-controls'); + return { + row: Boolean(row), + overflow: controls ? controls.scrollWidth - controls.clientWidth : -1, + pageOverflow: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + panelRight: panel?.getBoundingClientRect().right, + width: window.innerWidth, + }; + }, LAYER_ID); + record( + 'VIEWPORT: chips fit the phone-width panel', + fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0, + JSON.stringify(fit), + ); + await phone.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'), + }); + await phone.close(); + } finally { + await browser.close(); + } + const failed = results.filter((r) => !r.ok); + console.log( + `\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`, + ); + process.exit(failed.length ? 1 : 0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs new file mode 100644 index 000000000..41933141e --- /dev/null +++ b/scripts/qa-gnss.mjs @@ -0,0 +1,988 @@ +#!/usr/bin/env node +/** + * qa-gnss.mjs — headless proof for the GNSS Integrity layer + * (id `gnss-integrity`): low navigation accuracy reported by ADS-B. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server + * cache, one upstream read per whole-degree anchor); the other sections + * stub that route in the page so they are deterministic. + * + * (i) LIVE — over Poland and the southern Baltic the layer settles with no error, + * one geometry per cell and legend band counts that sum to the cell + * count; cells are required only when the returned rows, binned + * with the layer's own rules, reach the per-cell minimum (live + * traffic varies by hour). Requires a non-stale response no older than 60 s. + * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, + * a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell + * with 1 degraded is low (first bad discounted), a clean cell at sea + * is low, a 2-aircraft cell is withheld; `stale: true` reaches + * getStats. Every band is drawn and low is shown over land and sea. + * The row reads as low navigation accuracy, and stats carry the + * machine-readable provenance (GEV classifier, gpsjam bands, window). + * PICK verifies actual perimeter and empty-ground clicks under GNSS. + * CAMERA/GLOBE verify automatic sampling and the screen-centre anchor. + * (iii) FAILURE — the route stubbed to 502: stats.error is set and the + * cells already on the globe are kept. + * (iv) DISABLE — owned ground primitives are hidden. + * (v) KEYBOARD — the row's toggle button, focused, turns the layer on + * with Enter and off with Space, keeps focus and names its state. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend + * fit the panel width with no horizontal overflow. + * (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same + * stale snapshot observed 31 minutes ago draws nothing and reports no + * error; observed 29 minutes ago it draws its cells. The replay keeps + * its observation time instead of restarting the 30-minute window. + * + * Screenshots wait until every cell's ground geometry is built and show the + * open Data Layers row with its legend. Visual proof saved to + * qa-shots/gnss-*.png (gitignored). + * + * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 + * (also against a built preview: npm run build && npm run preview, + * then pass the preview URL) + * Optional: --timeout-ms 180000 for a slow boot. + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + accumulateGnssObservations, + binGnssCells, +} from '../src/layers/gnss/records.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000')); +const LAYER_ID = 'gnss-integrity'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Boot the app, dismiss the intro and place the camera straight down. */ +async function boot(page, { lon, lat, height }) { + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); + await page.evaluate( + (lo, la, h) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + // The app's intro flyTo animation clobbers a setView issued mid-flight. + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight active */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: h, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, + lon, + lat, + height, + ); +} + +/** Enable (or refresh) the layer and poll until it settles. */ +async function enableAndSettle(page, { refresh = false } = {}) { + return page.evaluate( + async (id, again) => { + const dm = window.__godsEyeView.dataManager; + const layer = dm.layers.get(id).module; + if (again) await dm.refreshLayer(id); + else await dm.setEnabled(id, true); + const stats = layer.getStats(); + const rowControls = layer.getRowControls().legend; + const legend = rowControls.map(({ label, count }) => ({ label, count })); + const blurb = rowControls[0]?.blurb ?? ''; + const rowName = + document.querySelector(`[data-layer-id="${id}"] .data-name`) + ?.textContent ?? ''; + const all = window.__godsEyeView.viewer.scene.groundPrimitives; + const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + ); + return { + stats, + legend, + blurb, + rowName, + name: layer.name, + entities: ds?.get(0)?.geometryInstances.length ?? 0, + shown: ds?.show ?? null, + }; + }, + LAYER_ID, + refresh, + ); +} + +/** Open the Data Layers panel and bring the GNSS row into view. */ +async function showLayerRow(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]').click(); + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(800); +} + +/** Actual card pixels on the shared canvas, available in dev and production. */ +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + +async function shoot(page, name) { + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: TIMEOUT_MS }, + ) + .catch(() => {}); + await showLayerRow(page); + await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) }); +} + +/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */ +function cellStates(id) { + const { viewer } = window.__godsEyeView; + const all = viewer.scene.groundPrimitives; + const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + ); + if (!ds?.length) return []; + return [ds.get(0).ready ? 0 : 1]; +} + +/** Wait until every GNSS cell's ground rectangle has finished building. */ +async function waitForBuiltCells(page, timeoutMs = 60000) { + const built = await page + .waitForFunction( + `(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`, + { timeout: timeoutMs, polling: 250 }, + ) + .then( + () => true, + () => false, + ); + const states = await page.evaluate( + `(${cellStates})(${JSON.stringify(LAYER_ID)})`, + ); + return { built, detail: `states=${JSON.stringify(states)}` }; +} + +const fixtureRow = (hex, lat, lon, degraded) => ({ + hex, + lat, + lon, + nic: degraded ? 0 : 8, + nacp: degraded ? 0 : 10, + gpsLost: false, + degraded, +}); + +/** + * Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad), + * low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft). + */ +function fixturePayload() { + const rows = []; + const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`; + for (let i = 0; i < 10; i++) + rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5)); + for (let i = 0; i < 30; i++) + rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2)); + for (let i = 0; i < 3; i++) + rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0)); + for (let i = 0; i < 4; i++) + rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false)); + for (let i = 0; i < 2; i++) + rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true)); + return { + fetchedAt: Date.now(), + anchor: { lat: 55, lon: 19 }, + radiusNm: 250, + rows, + stale: true, + }; +} + +/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */ +async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) { + await page.evaluateOnNewDocument( + (payload, age, failures, staleOverride) => { + window.__gnssQaAgeMs = age; + window.__gnssQaRequests = []; + window.__gnssQaFailures = failures; + const realFetch = window.fetch.bind(window); + window.__gnssQaMode = 'fixture'; + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input?.url || ''; + if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); + window.__gnssQaRequests.push(url); + if (window.__gnssQaFailures > 0) { + window.__gnssQaFailures--; + return Promise.resolve( + Response.json( + { error: 'rate_limited' }, + { + status: 429, + headers: { 'Retry-After': '5' }, + }, + ), + ); + } + const ok = window.__gnssQaMode === 'fixture'; + const body = ok + ? { + ...payload, + fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0), + ageMs: window.__gnssQaAgeMs || 0, + stale: staleOverride ?? payload.stale, + } + : { error: 'gnss_integrity_unavailable' }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: ok ? 200 : 502, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }; + }, + fixturePayload(), + ageMs, + initialFailures, + stale, + ); +} + +/** Toggle button of the GNSS row, as the panel draws it. */ +function toggleState(id) { + const button = document.querySelector( + `[data-layer-id="${id}"] .data-toggle-btn`, + ); + const layer = window.__godsEyeView.dataManager.layers.get(id); + return { + focused: document.activeElement === button, + label: button?.getAttribute('aria-label') ?? '', + enabled: Boolean(layer?.enabled), + count: layer?.module.getStats().count ?? -1, + }; +} + +/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */ +async function pressToggle(page, key, wantEnabled) { + await page.evaluate((id) => { + document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus(); + }, LAYER_ID); + await page.keyboard.press(key); + await page + .waitForFunction( + (id, want) => { + const layer = window.__godsEyeView.dataManager.layers.get(id); + return ( + Boolean(layer?.enabled) === want && + (!want || layer.module.getStats().count > 0) + ); + }, + { timeout: 30000, polling: 250 }, + LAYER_ID, + wantEnabled, + ) + .catch(() => {}); + return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`); +} + +async function main() { + console.log('\nGNSS Integrity proof (qa-gnss)'); + console.log(` App URL : ${APP_URL}\n`); + try { + const res = await fetch(APP_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error( + `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + ); + process.exit(2); + } + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath().catch(() => null)) || + undefined; + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(executablePath && fs.existsSync(executablePath) + ? { executablePath } + : {}), + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1440,900', + ], + }); + + let exitCode = 0; + const errors = []; + try { + // ── (i) LIVE ────────────────────────────────────────────────────────── + console.log( + '(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...', + ); + const live = await browser.newPage(); + await live.setViewport({ width: 1440, height: 900 }); + live.on('pageerror', (e) => errors.push(e.message)); + const liveBodies = []; + live.on('response', (res) => { + if (res.url().includes('/api/gnss-integrity') && res.ok()) + liveBodies.push(res.json()); + }); + await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 }); + const l = await enableAndSettle(live); + const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); + // Bin what adsb.lol actually returned with the layer's own rules: live + // traffic varies by hour, so cells are required only when some cell + // really reached the minimum aircraft count. + const liveSnapshots = (await Promise.allSettled(liveBodies)) + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value); + record( + 'LIVE: a successful, dated integrity response was received', + liveSnapshots.length > 0 && + liveSnapshots.every( + (body) => + Array.isArray(body.rows) && + Number.isFinite(body.ageMs) && + body.ageMs >= 0 && + body.ageMs <= 60000 && + body.stale !== true && + body.classifier === 'gev-nic-nacp-v2', + ), + `successful responses=${liveSnapshots.length}`, + ); + const liveRows = liveSnapshots.reduce( + (sum, body) => sum + (body.rows?.length || 0), + 0, + ); + const expectedStore = new Map(); + for (const body of liveSnapshots) { + const at = Date.now(); + accumulateGnssObservations(expectedStore, body.rows, at, { + observedAt: at - body.ageMs, + }); + } + const expectedCells = binGnssCells(expectedStore).length; + record( + 'LIVE: no error, one geometry per cell', + !l.stats.error && l.entities === l.stats.count, + `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`, + ); + if (expectedCells > 0) { + record( + 'LIVE: a snapshot with a qualifying cell yields cells', + l.stats.count > 0, + `rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`, + ); + } else { + console.log( + ` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`, + ); + } + record( + 'LIVE: legend band counts sum to the cell count', + legendTotal === l.stats.count, + JSON.stringify(l.legend), + ); + const liveBuilt = await waitForBuiltCells(live); + record( + 'LIVE: every cell rectangle is built on a parked camera', + liveBuilt.built, + liveBuilt.detail, + ); + await shoot(live, 'live-poland-baltic'); + await live.close(); + + // ── (ii) BANDS ──────────────────────────────────────────────────────── + console.log( + '\n(ii) BANDS — synthetic integrity rows through the real layer...', + ); + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + page.on('pageerror', (e) => errors.push(e.message)); + // Stub the route in-page rather than with request interception: an + // intercepted page stalls Cesium's geometry workers, so ground rectangles + // never finish building and the screenshot would not show real behaviour. + await stubRoute(page); + await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 }); + const f = await enableAndSettle(page); + const band = (prefix) => + f.legend.find(({ label }) => label.startsWith(prefix))?.count; + record( + 'BANDS: 4 cells shown, the 2-aircraft cell withheld', + f.stats.count === 4 && f.entities === 4, + `cells=${f.stats.count} entities=${f.entities}`, + ); + record( + 'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low', + band('Low-accuracy score >') === 1 && + band('Low-accuracy score 2') === 1 && + band('Low-accuracy score <') === 2, + JSON.stringify(f.legend), + ); + const prov = f.stats.provenance; + record( + 'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable', + prov?.classifier?.id === 'gev-nic-nacp-v2' && + prov.classifier.definedBy === 'GEV' && + prov.classifier.validated === false && + prov.aggregation?.source === 'https://gpsjam.org/faq' && + JSON.stringify(prov.aggregation.bands) === '[2,10]' && + prov.window?.minutes === 30 && + prov.window.scope === 'visited-view' && + prov.interpretation?.validated === false, + JSON.stringify(prov?.classifier), + ); + record( + 'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading', + f.name === 'GNSS Integrity' && + f.rowName.includes('GNSS Integrity') && + !/interference/i.test(f.rowName) && + f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) && + /one aircraft is discounted/.test(f.blurb) && + /not a detection/.test(f.blurb), + `name=${JSON.stringify(f.name)}`, + ); + record( + 'BANDS: stale snapshot is reported', + f.stats.stale === true, + `stale=${f.stats.stale}`, + ); + const bandsBuilt = await waitForBuiltCells(page); + record( + 'BANDS: every cell rectangle is built on a parked camera', + bandsBuilt.built, + bandsBuilt.detail, + ); + await shoot(page, 'synthetic-bands'); + + // GNSS fills must not own map input, including over a real perimeter. + await page.evaluate(async () => { + const original = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input.url; + if (url.includes('/api/fire-perimeters/inciweb')) + return Promise.resolve(Response.json({ rows: [] })); + if (url.includes('/api/fire-perimeters')) + return Promise.resolve( + Response.json({ + rows: [ + { + stableId: 'gnss-pick-qa', + name: 'GNSS PICK QA FIRE', + containedPct: 0, + acres: 100, + polygons: [ + [ + [ + [18.247, 54.247], + [18.253, 54.247], + [18.253, 54.253], + [18.247, 54.253], + [18.247, 54.247], + ], + ], + ], + }, + ], + }), + ); + return original(input, init); + }; + const { viewer, dataManager } = window.__godsEyeView; + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (18.25 * Math.PI) / 180, + latitude: (54.25 * Math.PI) / 180, + height: 2500, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await dataManager.setEnabled('fire-perimeters', true); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const v = window.__godsEyeView.viewer, + p = v.scene.pick({ + x: v.scene.canvas.clientWidth / 2, + y: v.scene.canvas.clientHeight / 2, + }); + return String(p?.id?.id ?? p?.id ?? '').startsWith( + 'fire-perimeter:gnss-pick-qa:', + ); + }, + { timeout: TIMEOUT_MS, polling: 250 }, + ); + const pickProbe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer, + c = v.scene.canvas; + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const empty = { x: centre.x + 300, y: centre.y }; + const picked = v.scene.pick(empty), + box = c.getBoundingClientRect(); + return { + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + surface: !picked || picked.id === undefined, + height: v.camera.positionCartographic.height, + }; + }); + record('PICK: a perimeter remains selectable under GNSS', true); + record( + 'PICK: street ground remains a surface pick under GNSS', + pickProbe.surface, + ); + const beforeCardPixels = await page.evaluate(paintedOverlayPixels); + await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y); + await page.waitForFunction( + `(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`, + { timeout: 10000, polling: 250 }, + ); + const selectedCardPixels = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: actual perimeter click paints its incident card', + selectedCardPixels > beforeCardPixels + 100, + `${beforeCardPixels} → ${selectedCardPixels} pixels`, + ); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'), + }); + await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y); + await page.waitForFunction( + `(${paintedOverlayPixels})() <= ${beforeCardPixels}`, + { timeout: 10000, polling: 250 }, + ); + const clearedCardPixels = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: empty ground clears the perimeter card', + clearedCardPixels <= beforeCardPixels, + `${selectedCardPixels} → ${clearedCardPixels} pixels`, + ); + await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, { + clickCount: 2, + }); + await sleep(1000); + record( + 'PICK: empty double-click does not zoom to a GNSS cell', + await page.evaluate( + (height) => + Math.abs( + window.__godsEyeView.viewer.camera.positionCartographic.height - + height, + ) < 200, + pickProbe.height, + ), + ); + await page.evaluate(async () => { + await window.__godsEyeView.dataManager.setEnabled( + 'fire-perimeters', + false, + ); + }); + console.log('\nCAMERA — automatic regional refresh without toggling...'); + const beforeMove = await page.evaluate( + () => window.__gnssQaRequests.length, + ); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (139.7 * Math.PI) / 180, + latitude: (35.7 * Math.PI) / 180, + height: 1_100_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 139.7, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + const moved = await page.evaluate( + (id) => ({ + requests: window.__gnssQaRequests.length, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + coverage: window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats().coverage, + info: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-controls-info`, + )?.textContent, + }), + LAYER_ID, + ); + record( + 'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row', + moved.enabled && + moved.requests > beforeMove && + moved.requests <= beforeMove + 2 && + moved.coverage?.longitude === 140 && + /Regional sample.*250 nm radius/.test(moved.info), + JSON.stringify(moved), + ); + await shoot(page, 'camera-refresh'); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (12.4 * Math.PI) / 180, + latitude: (41.9 * Math.PI) / 180, + height: 16_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + const params = url && new URL(url, location.origin).searchParams; + return ( + params && + Math.abs(Number(params.get('lon')) - 12.4) < 1 && + Math.abs(Number(params.get('lat')) - 41.9) < 1 + ); + }, + { timeout: 15000 }, + ); + const world = await page.evaluate((id) => { + const { viewer, dataManager } = window.__godsEyeView; + const bounds = viewer.camera.computeViewRectangle( + viewer.scene.globe.ellipsoid, + ); + return { + enabled: dataManager.isEnabled(id), + height: viewer.camera.positionCartographic.height, + boundsMidLongitude: + bounds && ((bounds.west + bounds.east) * 90) / Math.PI, + coverage: dataManager.layers.get(id).module.getStats().coverage, + }; + }, LAYER_ID); + record( + 'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius', + world.enabled && + world.height > 15_000_000 && + world.coverage?.longitude === 12 && + world.coverage?.latitude === 42 && + world.coverage?.radiusNm === 250, + JSON.stringify(world), + ); + await shoot(page, 'globe-regional'); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (18.6 * Math.PI) / 180, + latitude: (54.6 * Math.PI) / 180, + height: 450_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 18.6, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + + // ── (iii) FAILURE ───────────────────────────────────────────────────── + console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...'); + await page.evaluate(() => { + window.__gnssQaMode = 'error'; + }); + const e = await enableAndSettle(page, { refresh: true }); + record( + 'FAILURE: error surfaced, cells kept', + Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4, + `error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`, + ); + await shoot(page, 'failure'); + + // ── (iv) DISABLE ────────────────────────────────────────────────────── + console.log('\n(iv) DISABLE...'); + const shown = await page.evaluate(async (id) => { + await window.__godsEyeView.dataManager.setEnabled(id, false); + const all = window.__godsEyeView.viewer.scene.groundPrimitives; + return Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + )?.show; + }, LAYER_ID); + record('DISABLE: data source hidden', shown === false, `show=${shown}`); + await shoot(page, 'disabled'); + + // ── (v) KEYBOARD ────────────────────────────────────────────────────── + console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...'); + await page.evaluate(() => { + window.__gnssQaMode = 'fixture'; + }); + await showLayerRow(page); + const on = await pressToggle(page, 'Enter', true); + record( + 'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus', + on.enabled && + on.focused && + on.count === 4 && + /^GNSS Integrity: /.test(on.label) && + !/OFF$/.test(on.label), + JSON.stringify(on), + ); + const off = await pressToggle(page, ' ', false); + record( + 'KEYBOARD: Space on the focused toggle disables it and the label says OFF', + !off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label), + JSON.stringify(off), + ); + + // ── (vi) VIEWPORT ───────────────────────────────────────────────────── + console.log( + '\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...', + ); + await page.close(); + // A fresh page: switching an open page to isMobile reloads it, which + // would measure a half-booted app instead of the open row. + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + phone.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(phone); + await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 }); + await showLayerRow(phone); + await pressToggle(phone, 'Enter', true); + await showLayerRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const legend = [...row.querySelectorAll('.data-toggle-legend-item')]; + const rect = row.getBoundingClientRect(); + return { + width: window.innerWidth, + rowLeft: Math.round(rect.left), + rowRight: Math.round(rect.right), + overflow: row.scrollWidth - row.clientWidth, + legendItems: legend.length, + coverageInfo: row.querySelector('.data-toggle-controls-info') + ?.textContent, + legendInside: legend.every((item) => { + const r = item.getBoundingClientRect(); + return ( + r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1 + ); + }), + }; + }, LAYER_ID); + record( + 'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow', + fit.rowLeft >= 0 && + fit.rowRight <= fit.width && + fit.overflow <= 0 && + fit.legendItems === 3 && + /Regional sample.*250 nm radius/.test(fit.coverageInfo) && + fit.legendInside, + JSON.stringify(fit), + ); + await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') }); + await phone.close(); + + console.log( + '\nSTARTUP — initial rate limit stays enabled and recovers automatically...', + ); + const retry = await browser.newPage(); + await retry.setViewport({ width: 1440, height: 900 }); + retry.on('pageerror', (e) => errors.push(e.message)); + // Pages share the disposable test context's storage. Start this one cold, + // so a previous fixture cannot auto-enable GNSS before the START test. + await retry.evaluateOnNewDocument(() => localStorage.clear()); + await stubRoute(retry, { initialFailures: 1, stale: false }); + await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 }); + await showLayerRow(retry); + await retry.evaluate((id) => { + window.__gnssQaFailedStarts = 0; + window.__godsEyeView.dataManager.subscribe((change) => { + if (change.layerId === id && change.type === 'visibility-failed') + window.__gnssQaFailedStarts++; + }); + }, LAYER_ID); + const initial = await enableAndSettle(retry); + const limited = await retry.evaluate((id) => { + const dm = window.__godsEyeView.dataManager; + const row = document.querySelector(`[data-layer-id="${id}"]`); + return { + enabled: dm.isEnabled(id), + failedStarts: window.__gnssQaFailedStarts, + label: row.querySelector('.data-toggle-btn').textContent, + info: row.querySelector('.data-toggle-controls-info').textContent, + }; + }, LAYER_ID); + record( + 'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing', + limited.enabled && + limited.failedStarts === 0 && + limited.label === 'UNAVAILABLE' && + initial.stats.count === 0 && + initial.stats.retryInSec > 0 && + /rate limited.*Retrying automatically at/.test(limited.info), + JSON.stringify(limited), + ); + await retry.screenshot({ + path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'), + }); + await retry.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id); + return ( + layer.enabled && + layer.module.getStats().error === null && + layer.module.getStats().count === 4 + ); + }, + { timeout: 20000 }, + LAYER_ID, + ); + const recovered = await retry.evaluate( + (id) => ({ + requests: window.__gnssQaRequests.length, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + label: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-btn`, + ).textContent, + info: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-controls-info`, + ).textContent, + }), + LAYER_ID, + ); + record( + 'STARTUP: one automatic retry recovers to ON without toggling and clears the warning', + recovered.enabled && + recovered.requests === 2 && + recovered.label === 'ON' && + !/Retrying/.test(recovered.info), + JSON.stringify(recovered), + ); + await shoot(retry, 'startup-recovered'); + await retry.close(); + + // ── (vii) AGE ───────────────────────────────────────────────────────── + console.log( + '\n(vii) AGE — a replayed stale snapshot keeps its observation time...', + ); + const aged = await browser.newPage(); + await aged.setViewport({ width: 1440, height: 900 }); + aged.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(aged, { ageMs: 31 * 60_000 }); + await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 }); + const old = await enableAndSettle(aged, {}); + record( + 'AGE: a snapshot observed 31 min ago adds no cells and is not an error', + old.stats.count === 0 && old.entities === 0 && !old.stats.error, + `cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`, + ); + await aged.evaluate(() => { + window.__gnssQaAgeMs = 29 * 60_000; + }); + const recent = await enableAndSettle(aged, { refresh: true }); + record( + 'AGE: the same snapshot observed 29 min ago is still inside the window', + recent.stats.count === 4 && recent.entities === 4, + `cells=${recent.stats.count} entities=${recent.entities}`, + ); + await aged.close(); + record( + 'no uncaught browser errors', + errors.length === 0, + errors.slice(0, 3).join(' | '), + ); + } catch (err) { + console.error('\x1b[31mHarness error:\x1b[0m', err); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok).length; + const fail = results.length - pass; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 2a3d7328e..8709366de 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -1884,7 +1884,7 @@ async function runBrowserGroup(record) { await step('C15', async () => { // CRT/NVG/FLIR are DISPLAY LABELS; the real style ids are retro / - // surveillance / thermal (src/ui.js STYLE_STATUS_LABELS). Passing the + // surveillance / thermal (src/ui/visualPresets.js STYLE_STATUS_LABELS). Passing the // labels made every call a silent no-op that still "passed" — so assert // the returned visual state actually changed to the requested style. const wanted = [['retro', 'CRT'], ['surveillance', 'NVG'], ['thermal', 'FLIR'], ['normal', 'Normal']]; @@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) { for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 500)); - const status = document.getElementById('gev-voice-status')?.textContent?.trim() || ''; - if (status && status !== 'CONNECTING') break; + // The label is translated; the machine state is on the control. + const status = document.getElementById('gev-voice-control')?.dataset.status || ''; + if (status && status !== 'connecting') break; } let diag = null; try { diag = vc.getDiagnostics?.(); } catch { /* not started */ } diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 8d26dcb30..fb04d0025 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -86,14 +86,6 @@ function percentile(values, pct) { return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)]; } -function parseJson(value, fallback) { - try { - return JSON.parse(value); - } catch { - return fallback; - } -} - function analyzeMembership(samples) { let replacements = 0; let exposure = 0; diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs new file mode 100644 index 000000000..8a903918e --- /dev/null +++ b/scripts/qa-layer-visibility.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Real-browser proof for manager-owned periodic work and document visibility. + * Uses the app's development-only layer registration seam; no provider keys. + * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 + * Hiding uses a real background tab, never a patched document.hidden getter. + */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; + +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const url = new URL( + (urlIndex >= 0 ? args[urlIndex + 1] : undefined) || + process.env.QA_BASE_URL || + 'http://localhost:4173', +); +url.searchParams.set('welcome', '0'); +const browser = await puppeteer.launch({ + executablePath: chromeExecutablePath(), + headless: true, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const check = (name, actual, expected) => { + assert.equal(actual, expected, name); + console.log(`PASS ${name}: ${actual}`); +}; +try { + const page = await browser.newPage(); + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, + ); + await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + window.__qaVisibilityUpdates = 0; + window.__gevQaRegisterLayer(manager, { + id: 'qa-document-visibility', + name: 'Visibility QA', + updateInterval: 750, + init() {}, + enable() {}, + disable() {}, + destroy() {}, + async update() { + window.__qaVisibilityUpdates++; + return true; + }, + getStats() { + return { count: window.__qaVisibilityUpdates }; + }, + }); + await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' }); + }); + const count = () => page.evaluate(() => window.__qaVisibilityUpdates); + await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3); + check('periodic updates run while visible', (await count()) >= 3, true); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + const cover = await browser.newPage(); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + const hiddenCount = await count(); + await pause(2200); + check('hidden tab makes no periodic updates', await count(), hiddenCount); + check( + 'explicit refresh remains available while hidden', + await page.evaluate(() => + window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'), + ), + true, + ); + check('explicit hidden refresh runs once', await count(), hiddenCount + 1); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.waitForFunction( + () => + document.visibilityState === 'visible' && + window.__qaVisibilityUpdates > 0, + ); + await pause(200); + check( + 'resume performs one overdue refresh without a catch-up burst', + await count(), + hiddenCount + 2, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'qa-document-visibility', + false, + { origin: 'qa' }, + ), + ); + const stopped = await count(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await pause(1100); + check( + 'disabled layer does not resume on visibility change', + await count(), + stopped, + ); + check( + 'QA layer destroys cleanly', + await page.evaluate(() => + window.__gevQaUnregisterLayer( + window.__godsEyeView.dataManager, + 'qa-document-visibility', + ), + ), + true, + ); + console.log('RESULT: 7 passed'); +} finally { + await browser.close(); +} diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 606d67b83..46da4fe37 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -84,6 +84,63 @@ try { () => window.__godsEyeView.styleManager._activePoiIndex === 1, ), ); + const geocoderRequests = []; + const recordGeocoderRequest = (request) => { + const url = new URL(request.url()); + if ( + url.pathname === '/api/geocode' || + url.pathname === '/api/google/geocode' || + (url.hostname === 'photon.komoot.io' && url.pathname === '/api/') + ) + geocoderRequests.push(request.url()); + }; + page.on('request', recordGeocoderRequest); + await page.$eval('#location-search', (input) => { + input.value = 'geo:39.7392,-104.9903'; + input.focus(); + }); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => { + const state = + window.__godsEyeView.styleManager._locationLookup.getState(); + return ( + state.query === 'geo:39.7392,-104.9903' && state.status === 'found' + ); + }, + { timeout: 30000 }, + ); + await page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 10000 }, + ); + check( + 'native geo URI search shows the decimal coordinate readout', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + return ( + ui._searchedLocationLabel === '39.7392° N, 104.9903° W' && + ui._locationMiniCity.textContent.includes('39.7392° N') && + ui._locationMiniPoi.textContent === '104.9903° W' + ); + }), + ); + check( + 'geo URI navigation settles near the requested coordinate', + await page.evaluate(() => { + const position = window.__godsEyeView.viewer.camera.positionCartographic; + const degrees = 180 / Math.PI; + return ( + Math.abs(position.latitude * degrees - 39.7392) < 0.05 && + Math.abs(position.longitude * degrees + 104.9903) < 0.05 + ); + }), + ); + check( + 'geo URI search makes no geocoder request', + geocoderRequests.length === 0, + ); + page.off('request', recordGeocoderRequest); await page.evaluate(() => { const lookup = window.__godsEyeView.styleManager._locationLookup; window.__qaSearchRequests = []; @@ -96,6 +153,7 @@ try { window.__qaSearchRequests.push({ query, options, resolve }), ); document.getElementById('location-search').value = 'First'; + document.getElementById('location-search').focus(); }); await page.keyboard.press('Enter'); await page.waitForFunction(() => window.__qaSearchRequests.length === 1); diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 17b19f4c9..2876e0cac 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -156,14 +156,50 @@ const pressTabs = async (count, backwards = false) => { // The row scrolls smoothly when native Tab brings an edge button into view. await new Promise((resolve) => setTimeout(resolve, 250)); }; +// Phones (≤720px) present the dock's Presets inline in the More tab's sheet. +// It opens from the keyboard, as a keyboard user would, so the focus it moves +// into the sheet shows a ring. +const openPhoneMoreSheet = async () => { + const tab = '#mobile-tab-bar [data-mobile-sheet="more"]'; + if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) { + await page.focus(tab); + await page.keyboard.press('Enter'); + } + await page.focus(tab); + await page.keyboard.press('Enter'); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +}; + +// Media-query changes reach the page on a rendering step; wait until the +// phone shell agrees with the viewport before measuring. +const waitForShellSettled = () => page.waitForFunction(() => ( + document.body.classList.contains('mobile-shell') + === matchMedia('(max-width: 720px)').matches +), { timeout: 10_000, polling: 100 }); + +// The dock slides back into place when the layout leaves the phone shell. +const waitForDockSettled = () => page.waitForFunction(() => ( + !document.getElementById('command-dock').getAnimations({ subtree: true }) + .some((animation) => animation.playState === 'running') +), { timeout: 5_000 }); + const checkResponsiveStyleFocus = async (width) => { // The disclosure is only the starting boundary. Every style target below is // reached by actual Tab events, never by focusing the style under test. const selectedMapBefore = await page.evaluate(() => ( window.__godsEyeView.styleManager.mapStackController.getActiveId() )); - await page.focus('#control-panel-toggle'); - await pressTabs(2); // disclosure -> pin -> Normal + if (width <= 720) { + // Phones show Presets in the More tab's sheet, which focuses its first + // visible control (Normal) when it opens; the tray toggle and pin are + // desktop chrome there. + await openPhoneMoreSheet(); + } else { + await page.focus('#control-panel-toggle'); + await pressTabs(2); // disclosure -> pin -> Normal + } for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) { if (index) await pressTabs(3); const state = await styleFocusMetrics(); @@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({ ...await page.evaluate((layerId) => { const manager = window.__godsEyeView.dataManager; const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); - const list = document.getElementById('data-toggles'); + // The list scrolls on its own on desktop; in a phone sheet the sheet + // scrolls instead. Measure whichever container actually scrolls. + let list = document.getElementById('data-toggles'); + for (let node = list; node; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY; + if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) { + list = node; + break; + } + } return { lifecycle: manager.getLayerLifecycleState(layerId), feedState: button?.dataset.feedState, label: button?.textContent.trim(), @@ -1146,6 +1191,8 @@ try { await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 }); await new Promise((resolve) => setTimeout(resolve, 180)); + await waitForShellSettled(); + await openPhoneMoreSheet(); const at620 = await trayMetrics(); check( '620 px tray uses two rows without clipping', @@ -1179,7 +1226,13 @@ try { await page.screenshot({ path: path.join(shotsDir, '480-open.png') }); await checkResponsiveStyleFocus(480); + // Pinning and the compact wing are the narrow desktop dock (721–900 px); + // phones use the tab bar instead. + await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + await waitForDockSettled(); await page.click('.dock-pin-btn[data-pin-target="control-panel"]'); + await waitForDockSettled(); await page.click('#control-panel-toggle'); const cdp = await page.createCDPSession(); await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); @@ -1374,6 +1427,27 @@ try { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720) { + // Phones present Location as the Search tab's sheet: the keyboard opens + // it onto the field, and Escape returns focus to the tab. + const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]'; + await page.focus(searchTab); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const field = await focusMetrics('#location-search'); + check(`Search: ${width} px keyboard opens the sheet on the location field`, + field.focusedId === 'location-search' && field.visible && field.insideViewport, + JSON.stringify(field)); + await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) }); + await page.keyboard.press('Escape'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const tab = await focusMetrics(searchTab); + const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet); + check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`, + closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab })); + continue; + } await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); @@ -1489,6 +1563,12 @@ try { if (dataSetup.ready) { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) { + // Phones present Data Layers in the Layers tab's sheet. + await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await page.keyboard.press('Enter'); + } await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); // Resizing schedules rail placement on animation frames. Wait for that // pass and its CSS transitions before comparing a focus rectangle with @@ -1549,6 +1629,7 @@ try { ))); await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); await page.focus('#data-panel .panel-collapse-btn'); const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`); diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs index e04a05b86..8f6594011 100644 --- a/scripts/qa-overpass-offload.mjs +++ b/scripts/qa-overpass-offload.mjs @@ -306,7 +306,16 @@ try { await observeNetwork(page); page.on('pageerror', (error) => result.errors.push(error.message)); page.on('console', (message) => { - if (message.type() === 'error' && result.consoleErrors.length < 30) + if (message.type() !== 'error') return; + // A keyless server does not offer optional Google tokens. Keep every other + // HTTP or JavaScript error visible, including failures of this route other + // than its expected capability-miss 404. + const optionalTokenMiss = + message.location().url === new URL('/api/google/tiles-token', url).href && + /^Failed to load resource: the server responded with a status of 404\b/.test( + message.text(), + ); + if (!optionalTokenMiss && result.consoleErrors.length < 30) result.consoleErrors.push(message.text()); }); page.on('requestfailed', (request) => { diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs deleted file mode 100644 index 026b6f2fb..000000000 --- a/scripts/qa-panel-resize.mjs +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env node -/** - * Browser QA for the floating CCTV panel: lift, resize from every handle, - * minimum size, reload, and dock again. `--fail-on-retry` (or - * QA_FAIL_ON_RETRY=1) fails on any header-press retry. - */ -import assert from 'node:assert/strict'; -import { pathToFileURL } from 'node:url'; -import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js'; -import { - describePress, - dockPanelByDoubleClick, - dragPointer, - liftPanelByHeader, - nextFrames, -} from './qa-panelDrag.mjs'; -import { - hookRenderErrors, - readRenderErrors, - saveFailureArtifacts, - watchPage, -} from './qa-browserEvidence.mjs'; - -export const PANEL_ID = 'cctv-panel'; -export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; -export const MIN_SIZE = { width: 300, height: 160 }; -export const ARTIFACT_DIR = 'qa-artifacts/panel-resize'; -const VIEWPORT = { width: 1400, height: 900 }; -const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; - -/** Box edges a resize in `dir` must leave exactly where they were. */ -export function pinnedEdges(dir) { - return Object.entries(EDGE_BY_SIDE) - .filter(([side]) => !dir.includes(side)) - .map(([, edge]) => edge); -} - -/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */ -export function growthDelta(dir, amount) { - return { - dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0, - dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0, - }; -} - -/** - * Viewport point inside the handle for `dir`, given the panel's client rect. - * Edge strips straddle the border by 3px; corners and the grip sit inside. - */ -export function handlePoint(rect, dir) { - const inset = dir.length === 2 ? 4 : 1; - const x = dir.includes('w') - ? rect.left + inset - : dir.includes('e') - ? rect.right - inset - : rect.left + rect.width / 2; - const y = dir.includes('n') - ? rect.top + inset - : dir.includes('s') - ? rect.bottom - inset - : rect.top + rect.height / 2; - return { x, y }; -} - -/** Pinned edges that moved during a resize in `dir`. */ -export function driftedEdges(before, after, dir, tolerance = 1.5) { - const edges = (box) => ({ - left: box.left, - top: box.top, - right: box.left + box.width, - bottom: box.top + box.height, - }); - const a = edges(before); - const b = edges(after); - return pinnedEdges(dir).filter( - (edge) => Math.abs(a[edge] - b[edge]) > tolerance, - ); -} - -function near(actual, expected, tolerance, label) { - assert.ok( - Math.abs(actual - expected) <= tolerance, - `${label}: expected ${expected} ± ${tolerance}, got ${actual}`, - ); -} - -async function main() { - const { default: puppeteer } = await import('puppeteer'); - const args = process.argv.slice(2); - const urlIndex = args.indexOf('--url'); - const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; - if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; - const browser = await puppeteer.launch({ - headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || - (await puppeteer.executablePath()), - args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], - }); - const monitors = []; - try { - const page = await browser.newPage(); - await page.setViewport(VIEWPORT); - const errors = []; - monitors.push(watchPage(page, { name: 'panel', errors })); - await hookRenderErrors(page); - - const boot = async () => { - await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.__godsEyeView?.viewer, { - timeout: 90_000, - }); - await page.waitForSelector(`#${PANEL_ID} .panel-header`); - await page.evaluate(() => { - document.querySelector('.first-run-explore')?.click(); - }); - await page.keyboard.press('Escape'); - await page.evaluate(() => { - document.getElementById('first-run-launcher')?.remove(); - for (const node of document.querySelectorAll('[class*=first-run]')) - node.remove(); - }); - await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); - await nextFrames(page); - }; - const readPanel = () => - page.evaluate((id) => { - const panel = document.getElementById(id); - const rect = panel.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - // handlePoint() reads the far edges for s/e handles. - right: rect.right, - bottom: rect.bottom, - floating: panel.classList.contains('panel-floating'), - collapsed: panel.classList.contains('collapsed'), - inRail: panel.parentElement?.id === 'right-context-rail', - allocated: panel.style.getPropertyValue( - '--right-panel-allocated-height', - ), - inlineWidth: panel.style.width, - inlineHeight: panel.style.height, - stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), - }; - }, PANEL_ID); - /** - * Best effort: wait for the box to hold still for half a second. The lift - * is checked against the box at pointerdown, so a timeout only costs time. - */ - const settle = async () => { - try { - await page.waitForFunction( - (id) => { - const r = document.getElementById(id).getBoundingClientRect(); - const box = [r.left, r.top, r.width, r.height] - .map(Math.round) - .join(','); - const now = performance.now(); - if (window.__qaLastBox !== box) { - window.__qaLastBox = box; - window.__qaSince = now; - } - return now - window.__qaSince >= 500; - }, - { polling: 100, timeout: 20_000 }, - PANEL_ID, - ); - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - console.log('note: the CCTV panel was still moving; lifting anyway'); - } - }; - const headerPoint = async () => { - const rect = await page.evaluate((id) => { - const title = document.querySelector( - `#${id} .panel-header .panel-title`, - ); - const r = title.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }, PANEL_ID); - return rect; - }; - const drag = (from, dx, dy) => dragPointer(page, from, dx, dy); - - await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); - await page.evaluate((key) => { - localStorage.removeItem(key); - localStorage.removeItem('godsEyeView.v8.panelFloatHintShown'); - }, STORAGE_KEY); - await boot(); - - await page.evaluate((id) => { - const panel = document.getElementById(id); - if (panel.classList.contains('collapsed')) - document - .querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`) - .click(); - }, PANEL_ID); - await page.waitForFunction( - (id) => !document.getElementById(id).classList.contains('collapsed'), - {}, - PANEL_ID, - ); - // The rail animates panel heights, and on a slow software renderer the - // panels above CCTV can still be moving after a fixed sleep. - await settle(); - const docked = await readPanel(); - assert.equal(docked.floating, false); - assert.equal(docked.inRail, true); - - // Names whatever above CCTV in the rail shifted it, for a retry note. - const railAbove = () => - page.evaluate((id) => { - const out = []; - for (const node of document.getElementById('right-context-rail') - ?.children || []) { - if (node.id === id) break; - out.push( - `${node.id || node.className}:${Math.round(node.offsetHeight)}`, - ); - } - return out.join(' '); - }, PANEL_ID); - const aboveAtDock = await railAbove(); - // On CI a panel above CCTV grows after load and can shift the header out - // from under the press; the helper retries only such a provable miss. - const attempt = await liftPanelByHeader(page, PANEL_ID, { - dx: -200, - dy: -100, - note: async () => - `rail above CCTV ${aboveAtDock} -> ${await railAbove()}`, - }); - const { pressed } = attempt; - const lifted = await readPanel(); - assert.equal( - lifted.floating, - true, - `a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`, - ); - assert.equal( - lifted.collapsed, - false, - 'the drag-ending click must not collapse', - ); - const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`; - near( - lifted.left, - Math.max(6, pressed.left - 200), - 2, - `lifted left (${moved})`, - ); - near( - lifted.top, - Math.max(6, pressed.top - 100), - 2, - `lifted top (${moved})`, - ); - near(lifted.width, pressed.width, 2, 'lifted width'); - // The rail lets go of the lifted panel on its next layout pass. - await page - .waitForFunction( - (id) => - document - .getElementById(id) - .style.getPropertyValue('--right-panel-allocated-height') === '', - { timeout: 5_000 }, - PANEL_ID, - ) - .catch(() => {}); - const afterLayout = await readPanel(); - assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); - const railExcludes = await page.evaluate((id) => { - const rail = document.getElementById('right-context-rail'); - // Same rule as the rail: hidden panels take no room. - const counted = [...rail.children].filter((panel) => - panel.matches( - '[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])', - ), - ); - return { - expandedCount: rail.dataset.expandedCount, - counted: counted.length, - includesPanel: counted.some((panel) => panel.id === id), - }; - }, PANEL_ID); - assert.equal(railExcludes.includesPanel, false); - if (railExcludes.expandedCount !== undefined) - assert.equal(Number(railExcludes.expandedCount), railExcludes.counted); - const storedAfterLift = JSON.parse(afterLayout.stored); - assert.equal(storedAfterLift.floating, true); - assert.equal( - 'height' in storedAfterLift, - false, - 'a drag alone must not freeze the measured height', - ); - console.log('PASS: header drag lifts CCTV out of the rail'); - - // Park near the top: the voice dock would cover the south corners once - // the loop below has grown the window. - { - const header = await headerPoint(); - await drag(header, 0, 120 - header.y); - } - - const limits = { - minWidth: MIN_SIZE.width, - minHeight: MIN_SIZE.height, - viewportWidth: VIEWPORT.width, - viewportHeight: VIEWPORT.height, - }; - for (const dir of RESIZE_DIRECTIONS) { - const before = await readPanel(); - const { dx, dy } = growthDelta(dir, 40); - const point = handlePoint(before, dir); - const hit = await page.evaluate(({ x, y }) => { - const node = document.elementFromPoint(x, y); - if (!node) return 'nothing'; - const dirAttr = node.dataset?.dir - ? `[data-dir=${node.dataset.dir}]` - : ''; - return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`; - }, point); - await drag(point, dx, dy); - const after = await readPanel(); - const expected = resizeBox(before, dir, dx, dy, limits); - for (const key of ['left', 'top', 'width', 'height']) - near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`); - assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); - } - console.log('PASS: eight resize handles keep the opposite edge pinned'); - - let before = await readPanel(); - await drag(handlePoint(before, 'se'), -2000, -2000); - let after = await readPanel(); - near(after.width, MIN_SIZE.width, 2, 'min width (se)'); - near(after.height, MIN_SIZE.height, 2, 'min height (se)'); - assert.deepEqual(driftedEdges(before, after, 'se', 2), []); - before = after; - await drag(handlePoint(before, 'nw'), 2000, 2000); - after = await readPanel(); - near(after.width, MIN_SIZE.width, 2, 'min width (nw)'); - near(after.height, MIN_SIZE.height, 2, 'min height (nw)'); - assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); - console.log('PASS: minimum size holds from both corners'); - - const saved = await readPanel(); - const record = JSON.parse(saved.stored); - assert.equal(record.floating, true); - await boot(); - const restored = await readPanel(); - assert.equal(restored.floating, true, 'floating survives a reload'); - for (const key of ['left', 'top', 'width', 'height']) - near(restored[key], record[key], 2, `restored ${key}`); - console.log('PASS: reload restores position and size'); - - // A collapsed window keeps the height chosen for it, through a drag of - // its header strip and a reload. - const setCollapsed = async (collapsed) => { - await page.evaluate( - (id, want) => { - const panel = document.getElementById(id); - if (panel.classList.contains('collapsed') !== want) - document - .querySelector( - `.panel-collapse-btn[data-collapse-target="${id}"]`, - ) - .click(); - }, - PANEL_ID, - collapsed, - ); - await page.waitForFunction( - (id, want) => - document.getElementById(id).classList.contains('collapsed') === want, - {}, - PANEL_ID, - collapsed, - ); - await nextFrames(page); - }; - await drag(handlePoint(await readPanel(), 'se'), 0, 200); - const chosen = await readPanel(); - assert.ok( - chosen.height > MIN_SIZE.height + 150, - `the window grew to ${chosen.height}px`, - ); - await setCollapsed(true); - const strip = await readPanel(); - assert.ok(strip.height < chosen.height - 100, 'collapsed to its header'); - await drag(await headerPoint(), 40, 30); - const movedStrip = await readPanel(); - assert.equal(movedStrip.floating, true, 'the strip stays a window'); - near( - JSON.parse(movedStrip.stored).height, - chosen.height, - 2, - 'stored height while collapsed', - ); - await boot(); - await setCollapsed(false); - near( - (await readPanel()).height, - chosen.height, - 2, - 'height after expanding', - ); - console.log( - 'PASS: a collapsed window keeps its height through a drag and a reload', - ); - - const dock = await dockPanelByDoubleClick(page, PANEL_ID); - const snapped = await readPanel(); - assert.equal( - snapped.floating, - false, - `double-click snaps back (${describePress(dock)})`, - ); - assert.equal(snapped.inRail, true); - assert.equal(snapped.inlineWidth, ''); - assert.equal(snapped.inlineHeight, ''); - assert.equal(snapped.stored, null, 'the storage key is gone'); - console.log('PASS: header double-click returns the panel to the rail'); - - const renderErrors = await readRenderErrors(page); - assert.deepEqual( - [...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)], - [], - 'no page errors and no Cesium render-loop errors', - ); - console.log('PASS: no page errors and no render-loop errors'); - } catch (error) { - const dir = await saveFailureArtifacts({ - dir: ARTIFACT_DIR, - browser, - monitors, - error, - }).catch(() => null); - if (dir) console.error(`failure evidence saved in ${dir}/`); - throw error; - } finally { - await browser.close(); - } -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main().catch((error) => { - console.error(error.stack || error.message); - process.exit(1); - }); -} diff --git a/scripts/qa-panelDrag.mjs b/scripts/qa-panelDrag.mjs deleted file mode 100644 index fa6cc0c3a..000000000 --- a/scripts/qa-panelDrag.mjs +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Header press-and-verify for the QA gates that lift a rail panel into a - * floating window and dock it again. The rail animates panel heights, so a - * point read once can be under another panel by the time the pointer goes - * down. Only a provable miss (a recorded press outside the header) is - * retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the - * panel's own movement cannot explain fails the run instead. - */ - -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** Retries after the first attempt, and the pause before each. */ -export const PRESS_RETRIES = 2; -const RETRY_PAUSE_MS = 1000; - -/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */ -export function failOnRetryDefault( - env = process.env, - argv = process.argv.slice(2), -) { - return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry'); -} - -/** - * A failed attempt whose press landed outside the header. No recorded press - * is a failure, not a miss: a retry would only hide it. - * @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt - */ -export function pressMissed(attempt) { - return !attempt.done && attempt.pressed?.inHeader === false; -} - -/** Whether the panel's box changed by more than 2 px between two reads. */ -export function panelMoved(before, after) { - if (!before || !after) return false; - return ['left', 'top', 'width', 'height'].some( - (key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2, - ); -} - -/** The panel's box, rounded, as `left,top widthxheight`. */ -export function formatBox(box) { - if (!box) return 'unknown'; - const r = (value) => Math.round(value ?? NaN); - return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`; -} - -function panelBox(page, panelId) { - return page.evaluate((id) => { - const r = document.getElementById(id).getBoundingClientRect(); - return { left: r.left, top: r.top, width: r.width, height: r.height }; - }, panelId); -} - -export function headerPoint(page, panelId) { - return page.evaluate((id) => { - const title = document.querySelector(`#${id} .panel-header .panel-title`); - const r = title.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }, panelId); -} - -/** Name the element at a point, and whether it is the panel's header. */ -export function hitAt(page, point, panelId) { - return page.evaluate( - ({ x, y, id }) => { - const node = document.elementFromPoint(x, y); - if (!node) return { target: 'nothing', inHeader: false }; - return { - target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`, - inHeader: Boolean(node.closest(`#${id} .panel-header`)), - }; - }, - { ...point, id: panelId }, - ); -} - -/** - * Re-resolve the header point until it hits the header or the wait runs out - * (the press then records the miss). - */ -export async function headerTarget( - page, - panelId, - { tries = 20, intervalMs = 500 } = {}, -) { - let point = await headerPoint(page, panelId); - let hit = await hitAt(page, point, panelId); - for (let i = 0; i < tries && !hit.inHeader; i++) { - await delay(intervalMs); - point = await headerPoint(page, panelId); - hit = await hitAt(page, point, panelId); - } - const box = await panelBox(page, panelId); - return { point, hit, timedOut: !hit.inHeader, box }; -} - -/** - * Best effort: wait until the panel's box has held still for `stillMs`, so - * the header does not move between the hit check and the press. - */ -export async function waitForStill( - page, - panelId, - { stillMs = 300, timeout = 5000 } = {}, -) { - try { - await page.waitForFunction( - (id, ms) => { - const r = document.getElementById(id).getBoundingClientRect(); - const box = [r.left, r.top, r.width, r.height].map(Math.round).join(); - const now = performance.now(); - if (window.__qaStillBox !== box) { - window.__qaStillBox = box; - window.__qaStillSince = now; - } - return now - window.__qaStillSince >= ms; - }, - { polling: 100, timeout }, - panelId, - stillMs, - ); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -/** Record the next pointerdown's target and the panel's box at that moment. */ -export function recordNextPress(page, panelId) { - return page.evaluate((id) => { - window.__qaPressBox = null; - window.addEventListener( - 'pointerdown', - (event) => { - const r = document.getElementById(id).getBoundingClientRect(); - const target = event.target; - window.__qaPressBox = { - left: r.left, - top: r.top, - width: r.width, - height: r.height, - target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`, - inHeader: Boolean(target.closest?.(`#${id} .panel-header`)), - }; - }, - { capture: true, once: true }, - ); - }, panelId); -} - -function readPress(page) { - return page.evaluate(() => window.__qaPressBox ?? null); -} - -export async function waitForFloating( - page, - panelId, - floating, - { timeout = 3000 } = {}, -) { - try { - await page.waitForFunction( - (id, want) => - document.getElementById(id).classList.contains('panel-floating') === - want, - { polling: 50, timeout }, - panelId, - floating, - ); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -/** Two paints: by then every layout change an input caused is on screen. */ -export function nextFrames(page) { - return page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - ), - ); -} - -export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) { - await page.mouse.move(from.x, from.y); - await page.mouse.down(); - await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps }); - await page.mouse.move(from.x + dx, from.y + dy, { steps }); - await page.mouse.up(); - await nextFrames(page); -} - -export function describeRetry( - panelId, - attempt, - { round, retries, extra = '' }, -) { - const { hit, pressed, timedOut, still, box } = attempt; - return [ - `retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`, - `pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`, - `stillness ${still === false ? 'timed out' : 'held'}`, - `panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`, - ] - .concat(extra ? [extra] : []) - .join('; '); -} - -/** Retry `press` only while it provably misses the header. */ -async function pressUntilDecided( - page, - panelId, - press, - { - retries, - log, - note, - pauseMs = RETRY_PAUSE_MS, - failOnRetry = failOnRetryDefault(), - }, -) { - for (let round = 0; ; round++) { - const still = await waitForStill(page, panelId); - await recordNextPress(page, panelId); - const { point, hit, timedOut, box } = await headerTarget(page, panelId); - const done = await press(point); - const attempt = { - point, - hit, - timedOut, - still, - box, - done, - pressed: await readPress(page), - retries: round, - }; - if (round >= retries || !pressMissed(attempt)) return attempt; - const why = describeRetry(panelId, attempt, { - round, - retries, - extra: note ? await note() : '', - }); - if (failOnRetry && !panelMoved(box, attempt.pressed)) - throw new Error( - `QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`, - ); - log(`note: ${why}; retrying`); - log(`::warning title=Panel press retried::${why}`); - await delay(pauseMs); - } -} - -/** - * Lift a docked panel into a floating window with a header drag. - * @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>} - */ -export function liftPanelByHeader( - page, - panelId, - { - dx, - dy, - retries = PRESS_RETRIES, - log = console.log, - note, - pauseMs, - failOnRetry, - } = {}, -) { - return pressUntilDecided( - page, - panelId, - async (point) => { - await dragPointer(page, point, dx, dy); - return waitForFloating(page, panelId, true); - }, - { retries, log, note, pauseMs, failOnRetry }, - ); -} - -/** Dock a floating panel with a header double-click; same result as lifting. */ -export function dockPanelByDoubleClick( - page, - panelId, - { - retries = PRESS_RETRIES, - log = console.log, - note, - pauseMs, - failOnRetry, - } = {}, -) { - return pressUntilDecided( - page, - panelId, - async (point) => { - await page.mouse.click(point.x, point.y, { clickCount: 1 }); - await page.mouse.click(point.x, point.y, { clickCount: 2 }); - return waitForFloating(page, panelId, false); - }, - { retries, log, note, pauseMs, failOnRetry }, - ); -} - -/** Where an attempt pressed, for an assertion message. */ -export function describePress(attempt) { - const { point, hit, pressed } = attempt; - const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`; - if (!pressed) - return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`; - const n = attempt.retries || 0; - const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : ''; - return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`; -} diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index adbbfdf81..d78c3dbcc 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -167,6 +167,13 @@ async function main() { page.on('request', (request) => { const url = new URL(request.url()); + // Radio QA uses keyless maps; an optional Google token is not offered. + if (request.method() === 'GET' && url.origin === APP_ORIGIN + && url.pathname === '/api/google/tiles-token') { + request.respond({ status: 200, contentType: 'application/json', body: '{}' }); + return; + } + // These scenarios exercise Context lifecycle and keyboard ownership, not // live orbit accuracy. Reuse the tracking suite's fixed element sets so // CelesTrak outages cannot invalidate an otherwise clean UI run. @@ -1284,8 +1291,6 @@ async function main() { const clusterBadge = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); - const { radioStationIdFromPick } = await import('/src/data/radio.js'); const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations'); @@ -2754,13 +2759,16 @@ async function main() { const beforeSpace = radio.getUIState().audioState; // Simulate an already-started hold-Space session; click-started open mic // deliberately ignores Space takeover, and short taps never claim voice. - const priorPushToTalkMode = voice.pushToTalkMode; - voice.pushToTalkMode = true; + // The provider facade forwards reads, not writes. Set the mounted + // controller's input owner; assigning on the facade only shadows it. + const controller = voice.session.adapter.controller; + const priorPushToTalkMode = controller.pushToTalkMode; + controller.pushToTalkMode = true; document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); await new Promise((resolve) => setTimeout(resolve, 700)); const afterSpace = radio.getUIState().audioState; document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); - voice.pushToTalkMode = priorPushToTalkMode; + controller.pushToTalkMode = priorPushToTalkMode; await radio.play(); voice.setVoiceSpeaker('ai'); return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState }; diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 1460ed3b5..89d60d96f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -12,16 +12,22 @@ * Run: start the app (`./scripts/dev-fresh.sh`) and * node scripts/qa-recent-imagery.mjs * Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default - * qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without - * network access to NASA the run skips with exit code 0. + * qa-shots/recent-imagery), QA_HEADFUL=1. + * QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a + * 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s). + * Every wait is bounded. Without NASA access the run skips with code 0. */ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; const HEADFUL = process.env.QA_HEADFUL === '1'; +const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1'; +const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000; +const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12; const STEP_TIMEOUT = 20_000; const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 }; const PANEL = '#recent-imagery-panel'; @@ -49,8 +55,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ @@ -308,15 +313,21 @@ const CONTROLS = { }; const measure = () => page.evaluate((controls) => { + const panel = document.getElementById('recent-imagery-panel'); const body = document.getElementById('recent-imagery-panel-body'); + const panelRect = panel?.getBoundingClientRect(); const out = { bodyScroll: body?.scrollTop ?? null, rects: {} }; for (const [name, selector] of Object.entries(controls)) { const rect = document.querySelector(selector)?.getBoundingClientRect(); - out.rects[name] = rect - ? [rect.left, rect.top, rect.width, rect.height].map( - (v) => Math.round(v * 10) / 10, - ) - : null; + out.rects[name] = + rect && panelRect + ? [ + rect.left - panelRect.left, + rect.top - panelRect.top, + rect.width, + rect.height, + ].map((v) => Math.round(v * 10) / 10) + : null; } return out; }, CONTROLS); @@ -372,6 +383,9 @@ try { const initialStack = await page.evaluate(() => window.__riQa.activeStack()); const photoreal = initialStack === 'photoreal'; console.log(`initial map stack: ${initialStack}`); + console.log( + `stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`, + ); const onPhotorealOnly = (name, passed, detail) => photoreal ? check(name, passed, detail) @@ -1042,13 +1056,13 @@ try { ) <= target * 0.01, fitHeight, - 2_000, + ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`, + `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1258,7 +1272,8 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + const tail = (key) => + new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index 9dae69cdc..ab92c128d 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -66,7 +66,8 @@ try { ); }), ); - await page.click('#scene-capture-btn'); + await page.focus('#scene-capture-btn'); + await page.keyboard.press('Space'); await page.waitForFunction( () => document.querySelectorAll('.scene-shot-row').length === 1, ); @@ -328,6 +329,9 @@ try { await page.keyboard.press('Escape'); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); + // Phones present Scenes in the Layers tab's sheet. + await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await new Promise((resolve) => setTimeout(resolve, 400)); await page.screenshot({ path: path.join(shots, 'narrow.png') }); check( 'Scene controls stay inside the narrow viewport', diff --git a/scripts/qa-scene-dialog-file-drop.mjs b/scripts/qa-scene-dialog-file-drop.mjs new file mode 100644 index 000000000..9b48555ad --- /dev/null +++ b/scripts/qa-scene-dialog-file-drop.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a + * file dropped from the desktop, proved in a real browser. + * + * The dialog draws a translated row over the native file input + * (src/ui/sceneSharing.js, fileChooser). The input must still cover the row, + * or drops miss it. Drops a file on the row's centre via CDP and expects + * `input.files` to hold it. + * + * Usage: + * node scripts/qa-scene-dialog-file-drop.mjs + * + * Needs the repository's puppeteer (devDependencies). Exit status 0 when the + * drop selects the file, 1 otherwise. + */ +import http from 'node:http'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TYPES = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.json': 'application/json', + '.html': 'text/html', +}; + +const PAGE = ` +`; + +async function dropOn(client, x, y, file) { + const data = { items: [], files: [file], dragOperationsMask: 1 }; + for (const type of ['dragEnter', 'dragOver', 'drop']) { + await client.send('Input.dispatchDragEvent', { type, x, y, data }); + } +} + +async function main() { + const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-')); + const pack = path.join(dir, 'pack.json'); + await writeFile(pack, '{}'); + const server = http.createServer(async (req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(PAGE); + return; + } + try { + const body = await readFile(path.join(ROOT, req.url)); + res.writeHead(200, { + 'content-type': TYPES[path.extname(req.url)] || 'text/plain', + }); + res.end(body); + } catch { + res.writeHead(404).end(); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/`; + const browser = await puppeteer.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1000, height: 800 }); + const errors = []; + page.on('pageerror', (error) => errors.push(String(error))); + await page.goto(url); + await page.waitForFunction(() => window.ready === true); + const point = await page.evaluate(() => window.openDialog()); + const client = await page.createCDPSession(); + await dropOn(client, point.x, point.y, pack); + const names = await page.evaluate(() => + [...window.filesInput.files].map((file) => file.name), + ); + console.log(JSON.stringify({ names, errors })); + if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`); + if (names.length !== 1 || names[0] !== 'pack.json') { + throw new Error( + `expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`, + ); + } + console.log('OK: a file dropped on the visible control is selected'); + } finally { + await browser.close(); + server.close(); + await rm(dir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs new file mode 100644 index 000000000..7fece45ee --- /dev/null +++ b/scripts/qa-scope-hud.mjs @@ -0,0 +1,681 @@ +#!/usr/bin/env node +/** + * Scope and HUD browser acceptance. Requires the development server. + * Run: node scripts/qa-scope-hud.mjs + * Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo). + * Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space, + * feather 0/11/100/200, layout independence, + * share/scene restore, and visible text backing including rotated metadata. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +const check = (name, passed, detail) => { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) { + failures += 1; + if (detail) console.log(JSON.stringify(detail)); + } +}; +const readScope = () => + page.evaluate(() => { + const scope = window.__godsEyeView.styleManager.getVisualState().scope; + const canvas = document.getElementById('scope-mask'); + const context = canvas.getContext('2d'); + const radialX = Math.round( + canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35, + ); + return { + enabled: scope.enabled, + feather: scope.featherPct / 100, + cornerAlpha: context.getImageData(1, 1, 1, 1).data[3], + radialAlpha: context.getImageData( + radialX, + Math.round(canvas.height / 2), + 1, + 1, + ).data[3], + checked: document.getElementById('scope-toggle').checked, + disabled: document.getElementById('scope-feather-slider').disabled, + value: Number(document.getElementById('scope-feather-slider').value), + readout: document.getElementById('scope-feather-value').textContent, + }; + }); + +const readHudGlyphs = () => + page.evaluate(() => { + const hud = document.getElementById('intel-hud'); + const probe = document.createElement('span'); + probe.style.display = 'none'; + hud.appendChild(probe); + const baseline = { + color: getComputedStyle(probe).color, + shadow: getComputedStyle(probe).textShadow, + }; + probe.remove(); + const glyphs = [ + '.hud-mode', + '.hud-system', + '.hud-summary-label', + '.hud-summary', + '.hud-orbital', + '#hud-mgrs', + '#hud-coll', + ].map((selector) => { + const el = hud.querySelector(selector); + const style = getComputedStyle(el); + let opacity = 1; + for ( + let current = el; + current && current !== hud; + current = current.parentElement + ) + opacity *= Number(getComputedStyle(current).opacity); + return { + selector, + color: style.color, + shadow: style.textShadow, + opacity, + }; + }); + return { baseline, glyphs }; + }); + +async function settleTextBackings(enabled) { + // The pre-existing active-style value transitions all properties for 300ms. + // Wait for its backing to finish changing before inspecting or capturing it. + await page + .waitForFunction( + (scopeEnabled) => { + const expected = scopeEnabled + ? 'rgba(0, 0, 0, 0)' + : 'rgba(0, 0, 0, 0.7)'; + return [ + ...document.querySelectorAll( + '#intel-hud .hud-readout, .scene-text-holdout', + ), + ].every( + (el) => + getComputedStyle(el).backgroundColor === expected && + getComputedStyle(el).boxShadow === 'none', + ); + }, + { timeout: 2_000 }, + enabled, + ) + .catch(() => null); +} + +const readSceneChrome = () => + page.evaluate(() => { + const title = document.querySelector('#title-bar h1'); + const eye = title.querySelector('.title-logo'); + const subtitle = document.querySelector('#title-bar .subtitle'); + const label = document.querySelector('#style-indicator .indicator-label'); + const rect = (el, text = false) => { + if (!el.getClientRects().length) return null; + const range = document.createRange(); + range.selectNodeContents(el); + const box = text + ? range.getBoundingClientRect() + : el.getBoundingClientRect(); + return { + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + }; + }; + const titleBox = rect(title); + const eyeBox = rect(eye); + const backing = getComputedStyle(title, '::before'); + const plate = { + left: titleBox.left + parseFloat(backing.left), + top: titleBox.top + parseFloat(backing.top), + right: titleBox.right - parseFloat(backing.right), + bottom: titleBox.bottom - parseFloat(backing.bottom), + }; + const contains = (box) => + Object.entries(box).every(([side, value]) => + ['left', 'top'].includes(side) + ? plate[side] <= value + 0.5 + : plate[side] >= value - 0.5, + ); + const probe = document.createElement('span'); + document.body.appendChild(probe); + probe.style.color = 'var(--text-dim)'; + const dim = getComputedStyle(probe).color; + probe.style.color = 'var(--text-secondary)'; + const secondary = getComputedStyle(probe).color; + probe.remove(); + return { + positions: { + title: titleBox, + eye: eyeBox, + text: rect(title.querySelector('[translate="no"]'), true), + subtitle: rect(subtitle, true), + }, + titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)', + titleCovered: + backing.position === 'absolute' && + backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' && + contains(titleBox) && + contains(eyeBox), + leftAligned: + !rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5, + colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color], + dim, + secondary, + }; + }); + +function checkSceneChrome(name, before, after) { + const stable = Object.keys(before.positions).every((key) => { + const a = before.positions[key]; + const b = after.positions[key]; + return a && b + ? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5) + : a === b; + }); + check(`${name}: title, eye and subtitle stay in place across Scope`, stable, { + before, + after, + }); + check( + `${name}: OFF title plate covers the eye and aligns with the subtitle`, + before.titleUnbacked && after.titleCovered && after.leftAligned, + after, + ); + check( + `${name}: secondary labels brighten only with Scope OFF`, + before.colors.every((color) => color === before.dim) && + after.colors.every((color) => color === after.secondary), + { before: before.colors, after: after.colors }, + ); +} + +async function openApplication(url) { + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__godsEyeView?.styleManager?._applicationShortcuts, + { timeout: 60_000 }, + ); + await page.waitForFunction( + () => { + const cover = document.getElementById('loading-screen'); + return ( + cover?.classList.contains('hidden') && + Number(getComputedStyle(cover).opacity) === 0 + ); + }, + { timeout: 60_000 }, + ); +} + +try { + await page.setViewport({ width: 1440, height: 900 }); + const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); + url.searchParams.set('welcome', '0'); + // This browser has a fresh profile. Start without authored state so the + // first assertion exercises the defaults rather than a share-link restore. + url.hash = ''; + await openApplication(url); + const fresh = await readScope(); + check( + 'bare-URL fresh boot opens Scope ON with 100% feather', + fresh.enabled && + fresh.checked && + !fresh.disabled && + fresh.feather === 1 && + fresh.value === 100 && + fresh.readout === '100%' && + fresh.cornerAlpha > 0, + fresh, + ); + + // Use a known city view for canvas and readability checks below. + url.hash = + 'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l='; + // Changing only the hash does not rerun startup restoration. Leave the app + // document first so this fixture is applied by a new application boot. + await page.goto('about:blank'); + await openApplication(url); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }); + manager.hud.setMode('on'); + manager.setStyle('normal'); + }); + const initial = await readScope(); + check( + 'Austin fixture restores Scope ON and 100% feather', + initial.enabled && + initial.checked && + !initial.disabled && + initial.feather === 1 && + initial.value === 100 && + initial.readout === '100%' && + initial.cornerAlpha > 0, + initial, + ); + const name = await page.$eval('#scope-toggle', (el) => ({ + type: el.type, + label: el.labels?.[0]?.textContent.trim(), + })); + check( + 'Scope uses a native checkbox with a visible label', + name.type === 'checkbox' && name.label?.includes('Scope'), + name, + ); + await page.click('#scope-toggle'); + const off = await readScope(); + check( + 'click disables Scope and its feather slider', + !off.enabled && + !off.checked && + off.disabled && + off.feather === 1 && + off.cornerAlpha === 0, + off, + ); + await page.focus('#scope-toggle'); + await page.keyboard.press('Space'); + const on = await readScope(); + check( + 'Space re-enables the native checkbox without losing feather', + on.enabled && + on.checked && + !on.disabled && + on.feather === 1 && + on.cornerAlpha > 0, + on, + ); + + const paintedAlpha = []; + for (const pct of [0, 11, 100, 200]) { + await page.$eval( + '#scope-feather-slider', + (el, value) => { + el.value = String(value); + el.dispatchEvent(new Event('input', { bubbles: true })); + }, + pct, + ); + const state = await readScope(); + paintedAlpha.push(state.radialAlpha); + check( + `feather ${pct}% reaches the mask and readout`, + state.value === pct && + state.feather === pct / 100 && + state.readout === `${pct}%`, + state, + ); + } + check( + 'painted gradient widens beyond the old 100% ceiling', + paintedAlpha[0] === 0 && + paintedAlpha[1] === 0 && + paintedAlpha[2] > 0 && + paintedAlpha[3] > paintedAlpha[2], + paintedAlpha, + ); + await page.focus('#scope-feather-slider'); + await page.keyboard.press('Home'); + check('native Home reaches the hard edge', (await readScope()).feather === 0); + await page.keyboard.press('End'); + check( + 'native End reaches the expanded 200% maximum', + (await readScope()).feather === 2, + ); + await page.click('#scope-toggle'); + for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) { + await page.select('#hud-layout-select', layout); + const state = await readScope(); + check( + `Scope OFF and 200% survive ${layout}`, + !state.enabled && + !state.checked && + state.disabled && + state.feather === 2 && + state.value === 200 && + state.cornerAlpha === 0, + state, + ); + } + + const restored = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const links = manager.shareLinkManager; + manager._syncShareState(); + const params = links._buildHashParams(); + const encoded = { enabled: params.get('sc'), feather: params.get('scf') }; + await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } }); + await manager._visualSettings.restoreShareState({ + scopeEnabled: encoded.enabled === '1', + scopeFeatherPct: Number(encoded.feather), + }); + const shared = manager.getVisualState().scope; + await manager.applyVisualState({ + scope: { enabled: true, featherPct: 200 }, + }); + const scene = manager.getVisualState().scope; + await manager.applyVisualState({ + scope: { enabled: false, featherPct: 200 }, + }); + return { encoded, shared, scene }; + }); + check( + 'share state serializes and restores Scope OFF with 200%', + restored.encoded.enabled === '0' && + restored.encoded.feather === '200' && + restored.shared.enabled === false && + restored.shared.featherPct === 200, + restored, + ); + check( + 'scene state restores Scope ON with 200%', + restored.scene.enabled === true && restored.scene.featherPct === 200, + restored, + ); + + for (const layout of ['minimal', 'operator', 'tactical']) { + await page.select('#hud-layout-select', layout); + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: true, featherPct: 100 }, + }), + ); + await settleTextBackings(true); + const originalGlyphs = await readHudGlyphs(); + const originalChrome = await readSceneChrome(); + const original = await page.evaluate(() => { + const hud = document.getElementById('intel-hud'); + const wrappers = [...hud.querySelectorAll('.hud-readout')]; + const scene = [...document.querySelectorAll('.scene-text-holdout')]; + const unexpectedBackings = [...wrappers, ...scene] + .map((el) => { + const style = getComputedStyle(el); + return { + selector: el.id || el.className, + background: style.backgroundColor, + shadow: style.boxShadow, + }; + }) + .filter( + (style) => + style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none', + ); + return { + markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'), + unbacked: unexpectedBackings.length === 0, + unexpectedBackings, + wrappersTransparent: wrappers.every( + (el) => getComputedStyle(el).display === 'contents', + ), + operatorFrame: + hud.dataset.variant !== 'operator' || + (getComputedStyle(hud.querySelector('.hud-corner')) + .backgroundColor === 'rgba(0, 0, 0, 0.36)' && + getComputedStyle(hud.querySelector('.hud-top-bar')) + .backgroundImage !== 'none'), + }; + }); + check( + `${layout}: Scope ON retains the original presentation without new backplates`, + original.markerAbsent && + original.unbacked && + original.wrappersTransparent && + original.operatorFrame, + original, + ); + check( + `${layout}: original HUD color, glow and metadata alpha remain intact`, + originalGlyphs.glyphs.every( + (glyph) => + glyph.color === originalGlyphs.baseline.color && + glyph.shadow === originalGlyphs.baseline.shadow, + ) && + originalGlyphs.glyphs.find( + (glyph) => glyph.selector === '.hud-summary-label', + ).opacity === 0.55 && + originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital') + .opacity === 0.7 && + originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll') + .opacity === 0.5, + originalGlyphs, + ); + if (process.env.QA_SCREENSHOT_DIR) { + fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: path.join( + process.env.QA_SCREENSHOT_DIR, + `scope-on-${layout}.png`, + ), + }); + } + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: false }, + }), + ); + await settleTextBackings(false); + const backedGlyphs = await readHudGlyphs(); + checkSceneChrome(layout, originalChrome, await readSceneChrome()); + check( + `${layout}: Scope OFF adds backing without changing glyph styles`, + JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs), + backedGlyphs, + ); + const coverage = await page.evaluate(() => { + const hud = document.getElementById('intel-hud'); + const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT); + const missing = []; + let visible = 0; + let node; + while ((node = walker.nextNode())) { + if ( + !node.textContent.trim() || + node.parentElement.closest('.hud-bracket') + ) + continue; + const range = document.createRange(); + range.selectNodeContents(node); + if ( + ![...range.getClientRects()].some( + (rect) => rect.width > 0 && rect.height > 0, + ) + ) + continue; + visible += 1; + const backing = node.parentElement.closest('.hud-readout'); + const color = backing && getComputedStyle(backing).backgroundColor; + if ( + !backing || + color !== 'rgba(0, 0, 0, 0.7)' || + getComputedStyle(backing).boxShadow !== 'none' || + backing.parentElement.closest('.hud-readout') + ) + missing.push(node.textContent.trim()); + } + const wrapped = [...hud.querySelectorAll('.hud-readout')].filter( + (el) => el.offsetWidth > 0 && el.offsetHeight > 0, + ); + const empty = wrapped.filter((el) => !el.innerText.trim()).length; + const overlaps = []; + for (const parent of new Set(wrapped.map((el) => el.parentElement))) { + const siblings = wrapped.filter((el) => el.parentElement === parent); + for (let i = 1; i < siblings.length; i += 1) { + const previous = siblings[i - 1].getBoundingClientRect(); + const current = siblings[i].getBoundingClientRect(); + const horizontal = + Math.min(previous.right, current.right) - + Math.max(previous.left, current.left); + const vertical = + Math.min(previous.bottom, current.bottom) - + Math.max(previous.top, current.top); + // Horizontal bars share a vertical band. Only intersecting text boxes + // can stack their backing alpha; allow subpixel layout rounding. + if (horizontal > 0.5 && vertical > 0.5) { + overlaps.push({ + parent: parent.className, + previous: siblings[i - 1].innerText, + current: siblings[i].innerText, + vertical, + }); + } + } + } + const lineIds = [ + 'hud-mgrs', + 'hud-latlon', + 'hud-gsd', + 'hud-alt', + 'hud-timestamp', + 'hud-bottom-line', + ]; + const multiline = lineIds.filter((id) => { + const el = document.getElementById(id); + if (!el.getClientRects().length) return false; + const range = document.createRange(); + range.selectNodeContents(el); + return ( + range.getBoundingClientRect().height > + parseFloat(getComputedStyle(el).lineHeight) * 1.5 + ); + }); + const edges = [...hud.querySelectorAll('.hud-edge')] + .filter((el) => getComputedStyle(el).display !== 'none') + .map((el) => { + const box = el.querySelector('.hud-readout').getBoundingClientRect(); + return ( + getComputedStyle(el).transform !== 'none' && + box.height > box.width && + Number(getComputedStyle(el).opacity) === 1 + ); + }); + const broadBackings = [ + ...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'), + ].filter( + (el) => + el.offsetWidth > 0 && + getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)', + ); + return { + scopeOff: document.documentElement.dataset.scopeMask === 'off', + visible, + missing, + empty, + overlaps, + multiline, + edges, + broadBackings: broadBackings.length, + }; + }); + check( + `${layout}: all visible HUD text has one backing`, + coverage.scopeOff && + coverage.visible > 0 && + coverage.missing.length === 0 && + coverage.empty === 0 && + coverage.broadBackings === 0, + coverage, + ); + check( + `${layout}: adjacent readout backings do not overlap`, + coverage.overlaps.length === 0, + coverage, + ); + check( + `${layout}: desktop telemetry remains single line; rotated backing follows the labels`, + coverage.multiline.length === 0 && coverage.edges.every(Boolean), + coverage, + ); + if (process.env.QA_SCREENSHOT_DIR) { + fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: path.join( + process.env.QA_SCREENSHOT_DIR, + `scope-off-${layout}.png`, + ), + }); + } + } + const details = await page.evaluate(() => { + const summary = document.getElementById('hud-summary'); + summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30); + const ais = document.getElementById('hud-ais-vessel'); + ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION'; + return { + summaryClipped: + summary.scrollWidth > summary.clientWidth && + getComputedStyle(summary).textOverflow === 'ellipsis', + aisMultiline: + getComputedStyle(ais).whiteSpace === 'pre-line' && + ais.textContent.split('\n').length === 2 && + ais.getBoundingClientRect().height + 0.5 >= + parseFloat(getComputedStyle(ais).lineHeight) * 2, + recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color, + }; + }); + check( + 'summary clipping, multiline AIS and red REC dot retain their behavior', + details.summaryClipped && + details.aisMultiline && + details.recColor === 'rgb(255, 51, 51)', + details, + ); + await page.setViewport({ width: 390, height: 844 }); + await page.select('#hud-layout-select', 'minimal'); + const narrow = await page.evaluate(() => + [...document.querySelectorAll('#intel-hud .hud-readout')] + .filter( + (el) => + el.getClientRects().length && + getComputedStyle(el).display !== 'contents', + ) + .every((el) => { + const box = el.getBoundingClientRect(); + return box.left >= 0 && box.right <= innerWidth; + }), + ); + check('narrow HUD text backings stay within the viewport', narrow); + for (const layout of ['minimal', 'operator', 'tactical']) { + await page.select('#hud-layout-select', layout); + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: true }, + }), + ); + await settleTextBackings(true); + const before = await readSceneChrome(); + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: false }, + }), + ); + await settleTextBackings(false); + checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome()); + } + check('runtime has no uncaught browser errors', errors.length === 0, errors); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-segmented-controls.mjs b/scripts/qa-segmented-controls.mjs new file mode 100644 index 000000000..8612c2f23 --- /dev/null +++ b/scripts/qa-segmented-controls.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), + headless: true, + args: ['--use-gl=angle', '--use-angle=swiftshader'], +}); +const page = await browser.newPage(); +const errors = []; +const failures = []; +page.on('pageerror', (error) => errors.push(error.message)); +const check = (name, passed, detail = '') => { + console.log( + `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`, + ); + if (!passed) failures.push(name); +}; +const base = process.env.QA_BASE_URL || 'http://localhost:4179'; +const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls'; +fs.mkdirSync(shots, { recursive: true }); + +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__godsEyeView?.styleManager?._applicationShortcuts, + { timeout: 60000 }, + ); + await page.waitForFunction( + () => + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.evaluate(() => + window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }), + ); + + const surface = await page.evaluate(() => { + const groups = [...document.querySelectorAll('[role="radiogroup"]')]; + const buttons = groups.flatMap((group) => [ + ...group.querySelectorAll('[role="radio"]'), + ]); + return { + groups: groups.length, + buttons: buttons.length, + shared: + groups.every((g) => g.classList.contains('gev-segmented')) && + buttons.every((b) => { + const style = getComputedStyle(b); + return ( + b.classList.contains('gev-segment') && + style.fontSize === '9px' && + style.minHeight === '24px' && + style.borderRadius === '0px' + ); + }), + }; + }); + check( + 'all six one-of-many groups use the shared surface', + surface.groups === 6 && surface.buttons === 15 && surface.shared, + JSON.stringify(surface), + ); + + await page.evaluate(() => + document.getElementById('models3d-mode-all').click(), + ); + check( + 'model selection reaches both aircraft layers', + await page.evaluate(() => { + const dm = window.__godsEyeView.dataManager; + return ( + ['flights', 'military'].every( + (id) => dm.layers.get(id).module.getParams().models3dMode === 'all', + ) && + document + .getElementById('models3d-mode-all') + .getAttribute('aria-checked') === 'true' + ); + }), + ); + await page.focus('#models3d-mode-all'); + await page.keyboard.press('ArrowLeft'); + check( + 'Display retains its click-only arrow policy', + await page.evaluate( + () => + document + .getElementById('models3d-mode-all') + .getAttribute('aria-checked') === 'true', + ), + ); + await page.focus('#models3d-mode-proximity'); + await page.keyboard.press('Space'); + check( + 'native Space selects model coverage', + await page.evaluate( + () => + document + .getElementById('models3d-mode-proximity') + .getAttribute('aria-checked') === 'true', + ), + ); + await page.evaluate(() => + document.getElementById('detection-allocation-weighted').click(), + ); + check( + 'allocation selection still updates application tuning', + await page.evaluate( + () => + window.__godsEyeView.styleManager._detectionAllocationPreference === + 'WEIGHTED' && + document + .getElementById('detection-allocation-weighted') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => + document.querySelector('[data-shape="pin"]').click(), + ); + check( + 'drawing shape still uses its existing feature action', + await page.evaluate( + () => + window.__gevDrawTool?.shape === 'pin' && + document + .querySelector('[data-shape="pin"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + // Off-layer controls still own params; no catalog/provider request is needed. + await page.evaluate(() => + document.querySelector('[data-sl-pano="flat"]').click(), + ); + check( + 'panorama choice reaches the Street Level layer', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getParams().pano === 'flat', + ), + ); + await page.evaluate(() => + document.querySelector('[data-sl-pano="flat"]').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Home', + bubbles: true, + cancelable: true, + }), + ), + ); + check( + 'Street Level Home retains selection and roving tab state', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getParams().pano === 'all' && + document + .querySelector('[data-sl-pano="all"]') + .getAttribute('tabindex') === '0', + ), + ); + await page.evaluate(() => + document.querySelector('[data-sl-render="fill"]').click(), + ); + check( + 'fit/fill keeps its stored render preference', + await page.evaluate( + () => + localStorage.getItem('gev:street-level:render-mode') === 'fill' && + document + .querySelector('[data-sl-render="fill"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => + document.querySelector('.ri-mode-btn[data-mode="ab"]').click(), + ); + check( + 'imagery mode reaches its controlled layer state', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('recent-imagery') + .module.getSnapshot().mode === 'ab', + ), + ); + await page.evaluate(() => + document.getElementById('ri-mode').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }), + ), + ); + check( + 'imagery arrows wrap from the current selected value', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('recent-imagery') + .module.getSnapshot().mode === 'image' && + document + .querySelector('.ri-mode-btn[data-mode="image"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setDetectionAllocation('ELASTIC', { + syncShare: false, + persist: false, + }); + document + .getElementById('models3d-mode-proximity') + .scrollIntoView({ block: 'center' }); + }); + await page.screenshot({ path: `${shots}/desktop.png` }); + await page.setViewport({ width: 620, height: 900 }); + await page.evaluate(() => + document + .getElementById('models3d-mode-proximity') + .scrollIntoView({ block: 'center' }), + ); + await page.screenshot({ path: `${shots}/narrow.png` }); + check( + 'narrow selectors stay within their panel', + await page.evaluate(() => + [...document.querySelectorAll('.pp-mode-seg')] + .filter((g) => g.getClientRects().length) + .every((group) => group.scrollWidth <= group.clientWidth + 1), + ), + ); + const cyber = await page.evaluate(() => { + const root = document.documentElement; + const previous = root.getAttribute('data-ui-theme'); + root.setAttribute('data-ui-theme', 'cyber'); + const selected = [ + ...document.querySelectorAll('.gev-segment[aria-checked="true"]'), + ]; + // Check the final theme colors, independent of software-GL frame cadence. + for (const button of selected) + for (const animation of button.getAnimations()) animation.finish(); + const backgrounds = selected.map( + (button) => getComputedStyle(button).backgroundColor, + ); + const radii = [...document.querySelectorAll('.gev-segmented')].map( + (group) => getComputedStyle(group).borderRadius, + ); + const consistent = + selected.length === 6 && + backgrounds.every((color) => color === backgrounds[0]) && + radii.every((radius) => radius === '1px'); + if (previous === null) root.removeAttribute('data-ui-theme'); + else root.setAttribute('data-ui-theme', previous); + return { consistent, count: selected.length, backgrounds, radii }; + }); + check( + 'Cyber theme styles every selected segment consistently', + cyber.consistent, + JSON.stringify(cyber), + ); + await page.evaluate(async () => { + const { dataManager, styleManager } = window.__godsEyeView; + await dataManager.setEnabled('recent-imagery', true); + styleManager.setPanelCollapsed('recent-imagery-panel', false, { + persist: false, + syncShare: false, + }); + }); + for (const width of [1440, 620, 390]) { + await page.setViewport({ width, height: 900 }); + await page.focus('.ri-mode-btn[aria-checked="true"]'); + const modeLayout = await page.evaluate(() => { + const group = document.getElementById('ri-mode'); + const bounds = group.getBoundingClientRect(); + const style = getComputedStyle(group); + const top = bounds.top + parseFloat(style.borderTopWidth); + const bottom = bounds.bottom - parseFloat(style.borderBottomWidth); + const buttons = [...group.querySelectorAll('[role="radio"]')]; + return { + visible: bounds.width > 0 && bounds.height > 0, + contained: buttons.every((button) => { + const rect = button.getBoundingClientRect(); + return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5; + }), + groupHeight: bounds.height, + buttonHeights: buttons.map( + (button) => button.getBoundingClientRect().height, + ), + }; + }); + check( + `imagery mode buttons and focus outline fit their row at ${width}px`, + modeLayout.visible && modeLayout.contained, + JSON.stringify(modeLayout), + ); + } + check('no uncaught browser errors', errors.length === 0, errors.join(' | ')); +} catch (error) { + console.error('Browser errors:', errors); + await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {}); + throw error; +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures.length} failures`); +process.exitCode = failures.length ? 1 : 0; diff --git a/scripts/qa-sky-model-lighting.mjs b/scripts/qa-sky-model-lighting.mjs new file mode 100644 index 000000000..2f401506b --- /dev/null +++ b/scripts/qa-sky-model-lighting.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Lit airplane regression: StarrySky must match a black environment baseline. + * A white environment is a positive control: it must visibly brighten the + * same model. Each case uses a fresh scene and dynamic environment map. + * Usage: node scripts/qa-sky-model-lighting.mjs [--url http://localhost:4333/] + */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; + +const i = process.argv.indexOf('--url'); +const base = i > 0 ? process.argv[i + 1] : 'http://localhost:4333/'; +const url = new URL('/__qa_sky_model__', base); +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--ignore-gpu-blocklist'], +}); +try { + const page = await browser.newPage(); + page.on('pageerror', (error) => console.log('PAGEERR', error.message)); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.url() === url.href) + request.respond({ + contentType: 'text/html', + body: '', + }); + else if (new URL(request.url()).origin !== url.origin) request.abort(); + else request.continue(); + }); + await page.goto(url.href); + const results = await page.evaluate(async () => { + window.CESIUM_BASE_URL = '/cesium/'; + // Use StarrySky's exact Vite dependency URL, including its version query. + const source = await fetch('/src/sky/starrySky.js').then((r) => r.text()); + const cesiumUrl = source.match( + /from ["']([^"']*\/cesium\.js[^"']*)["']/, + )?.[1]; + if (!cesiumUrl) throw new Error('run against the Vite dev server'); + const C = await import(cesiumUrl); + const { StarrySky } = await import('/src/sky/starrySky.js'); + const time = C.JulianDate.fromIso8601('2026-10-19T00:00:00Z'); + const results = {}; + for (const kind of ['black', 'starry', 'white']) { + const canvas = document.createElement('canvas'); + canvas.width = 400; + canvas.height = 300; + document.body.append(canvas); + const scene = new C.Scene({ canvas }); + scene.rethrowRenderErrors = true; + let cubeMap; + try { + scene.backgroundColor = C.Color.BLACK; + scene.light = new C.DirectionalLight({ + direction: new C.Cartesian3(1, 0, 0), + intensity: 0.2, + }); + if (kind === 'starry') { + scene.skyBox = new StarrySky({ requestRender() {} }); + scene.skyBox._loading = true; // visible sky is outside this probe + } else { + const value = kind === 'white' ? 255 : 0; + const face = { + width: 1, + height: 1, + arrayBufferView: new Uint8Array([value, value, value, 255]), + }; + cubeMap = new C.CubeMap({ + context: scene.context, + source: Object.fromEntries( + [...C.CubeMap.faceNames()].map((name) => [name, face]), + ), + }); + scene.skyBox = { _cubeMap: cubeMap, update() {}, destroy() {} }; + } + const position = C.Cartesian3.fromDegrees(0, 0, 10000); + const model = await C.Model.fromGltfAsync({ + url: '/models/airplane.glb', + modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position), + }); + scene.primitives.add(model); + scene.camera.lookAt(position, new C.HeadingPitchRange(0.4, -0.5, 45)); + let framed = false; + for (let frame = 0; frame < 240; frame++) { + scene.initializeFrame(); + scene.render(time); + if (model.ready && !framed) { + scene.camera.viewBoundingSphere( + model.boundingSphere, + new C.HeadingPitchRange( + 0.4, + -0.5, + model.boundingSphere.radius * 3, + ), + ); + framed = true; + } + await new Promise(requestAnimationFrame); + } + if (!model.ready || !model.environmentMapManager.radianceCubeMap) + throw new Error(`${kind}: model or dynamic lighting did not load`); + scene.initializeFrame(); + scene.render(time); + const gl = scene.context._gl; + const px = new Uint8Array(canvas.width * canvas.height * 4); + gl.readPixels( + 0, + 0, + canvas.width, + canvas.height, + gl.RGBA, + gl.UNSIGNED_BYTE, + px, + ); + let total = 0; + let visible = 0; + for (let p = 0; p < px.length; p += 4) { + const luma = 0.299 * px[p] + 0.587 * px[p + 1] + 0.114 * px[p + 2]; + total += luma; + if (luma > 1) visible++; + } + results[kind] = { total, visible }; + } finally { + scene.destroy(); + cubeMap?.destroy(); + canvas.remove(); + } + } + return results; + }); + console.log(JSON.stringify(results)); + assert.ok(results.black.visible > 100, 'the lit model must be visible'); + assert.ok( + Math.abs(results.starry.total - results.black.total) <= + results.black.total * 0.01, + 'StarrySky must match black environment lighting within 1%', + ); + assert.ok( + results.white.total > results.black.total * 1.03, + 'white environment control must brighten the model by at least 3%', + ); + console.log( + 'PASS: lit airplane matches black sky; white control is brighter', + ); +} finally { + await browser.close(); +} diff --git a/scripts/qa-status-badges.mjs b/scripts/qa-status-badges.mjs new file mode 100644 index 000000000..961fdba7f --- /dev/null +++ b/scripts/qa-status-badges.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + headless: true, + args: ['--use-gl=angle', '--use-angle=swiftshader'], +}); +const page = await browser.newPage(); +page.setDefaultNavigationTimeout(60000); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let checks = 0; +let failures = 0; +const check = (name, passed, detail) => { + checks++; + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) { + failures++; + console.log(JSON.stringify(detail)); + } +}; +const out = 'qa-shots/status-badges'; +fs.mkdirSync(out, { recursive: true }); +try { + for (const lang of ['en', 'ru']) { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._cctvControls && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.evaluate(async () => { + const { dataManager, styleManager: manager } = window.__godsEyeView; + await dataManager.setEnabled('wind', true); + manager.setPanelCollapsed('weather-panel', false, { + persist: false, + syncShare: false, + }); + manager.setPanelCollapsed('cctv-panel', false, { + persist: false, + syncShare: false, + }); + const owner = manager._cctvControls; + owner._cctvUnsubscribe?.(); + owner._cctvUnsubscribe = null; + owner.actions = { ...owner.actions, isEnabled: () => true }; + owner._renderCctvState({ + enabled: true, + activeCameraId: 'badge-fixture', + cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }], + activeCamera: { + id: 'badge-fixture', + city: 'QA', + headingDeg: 30, + pitchDeg: -10, + fovDeg: 45, + rangeM: 120, + mountHeightM: 8, + sourceKind: 'snapshot', + sourceStatus: 'degraded', + calBadge: 'curated', + calDirty: false, + }, + }); + window.__qaBadgeText = + document.getElementById('cctv-source-badge').textContent; + }); + await page.waitForSelector('.weather-coverage.gev-status-badge'); + const inventory = await page.evaluate(() => { + const source = document.getElementById('cctv-source-badge'); + const quality = document.getElementById('cctv-quality-chip'); + const coverage = [...document.querySelectorAll('.weather-coverage')]; + return { + shared: [source, quality, ...coverage].every((node) => + node.classList.contains('gev-status-badge'), + ), + live: + source.getAttribute('role') === 'status' && + source.getAttribute('aria-live') === 'polite', + silent: [quality, ...coverage].every( + (node) => + !node.hasAttribute('aria-live') && + !node.hasAttribute('role') && + !node.hasAttribute('tabindex'), + ), + coverage: + coverage.length > 0 && + coverage.every( + (node) => + node.textContent && + !node.hidden && + node.classList.contains('gev-status-badge--plain'), + ), + source: source.textContent, + }; + }); + check( + `${lang}: CCTV and populated Weather badges share the view`, + inventory.shared && inventory.coverage, + inventory, + ); + check( + `${lang}: existing announcement and read-only semantics remain intact`, + inventory.live && inventory.silent, + inventory, + ); + check( + `${lang}: source wording uses the active locale`, + lang === 'en' + ? inventory.source === 'SNAPSHOT · DEGRADED' + : inventory.source !== 'SNAPSHOT · DEGRADED', + inventory, + ); + + for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) { + const result = await page.evaluate((state) => { + const owner = window.__godsEyeView.styleManager._cctvControls; + owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : ''; + owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state) + ? 'true' + : ''; + owner._cctvFrameWrap.classList.toggle( + 'has-frame', + state === 'retained', + ); + owner._syncCctvSourceBadge( + owner._cctvState.activeCamera, + state !== 'idle', + ); + const badge = document.getElementById('cctv-source-badge'); + return { + state: badge.dataset.frameState, + text: badge.textContent, + color: getComputedStyle(badge).color, + }; + }, state); + const expected = { + loading: ['loading', 'rgb(255, 229, 155)'], + error: ['error', 'rgb(255, 176, 176)'], + retained: ['ready', 'rgb(157, 239, 255)'], + ready: ['ready', 'rgb(157, 239, 255)'], + idle: ['idle', 'rgb(157, 239, 255)'], + }[state]; + check( + `${lang}: ${state} keeps its frame state and palette`, + result.state === expected[0] && + result.color === expected[1] && + (!['ready', 'retained'].includes(state) || + result.text === inventory.source), + result, + ); + } + for (const [calBadge, dirty, color] of [ + ['raw-prior', false, 'rgb(143, 231, 255)'], + ['curated', false, 'rgb(255, 230, 162)'], + ['calibrated', false, 'rgb(173, 255, 191)'], + ['curated', true, 'rgb(255, 208, 138)'], + ]) { + const result = await page.evaluate( + ({ calBadge, dirty }) => { + const owner = window.__godsEyeView.styleManager._cctvControls; + document.getElementById('cctv-next-btn').focus(); + owner._renderCctvState({ + ...owner._cctvState, + activeCamera: { + ...owner._cctvState.activeCamera, + calBadge, + calDirty: dirty, + }, + }); + const badge = document.getElementById('cctv-quality-chip'); + return { + state: badge.dataset.calBadge, + color: getComputedStyle(badge).color, + focus: document.activeElement.id, + }; + }, + { calBadge, dirty }, + ); + check( + `${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`, + result.state === (dirty ? 'edited' : calBadge) && + result.color === color && + result.focus === 'cctv-next-btn', + result, + ); + } + for (const theme of ['tactical', 'cyber']) { + await page.evaluate( + (theme) => window.__godsEyeView.styleManager._setHudVariant(theme), + theme, + ); + for (const width of [1440, 620, 390]) { + await page.setViewport({ width, height: 900 }); + for (const panelId of ['cctv-panel', 'weather-panel']) { + await page.evaluate(async (panelId) => { + const manager = window.__godsEyeView.styleManager; + for (const other of ['cctv-panel', 'weather-panel']) { + if (other !== panelId) + manager.setPanelCollapsed(other, true, { + persist: false, + syncShare: false, + }); + } + manager.setPanelCollapsed(panelId, false, { + persist: false, + syncShare: false, + }); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + }, panelId); + const layout = await page.evaluate((panelId) => { + const selector = + panelId === 'cctv-panel' + ? '#cctv-source-badge, #cctv-quality-chip' + : '.weather-coverage'; + return [...document.querySelectorAll(selector)].map((node) => { + const rect = node.getBoundingClientRect(); + const panel = node.closest(`#${panelId}`).getBoundingClientRect(); + return { + id: node.id || 'weather-coverage', + text: node.textContent, + width: rect.width, + height: rect.height, + scroll: node.scrollWidth, + client: node.clientWidth, + left: rect.left, + right: rect.right, + panelLeft: panel.left, + panelRight: panel.right, + fits: + rect.width > 0 && + rect.height > 0 && + rect.left >= panel.left - 1 && + rect.right <= panel.right + 1 && + node.scrollWidth <= node.clientWidth + 1, + }; + }); + }, panelId); + check( + `${lang}/${theme}: ${panelId} badges fit at ${width}px`, + layout.length >= (panelId === 'cctv-panel' ? 2 : 1) && + layout.every((node) => node.fits), + layout, + ); + if (width === 390) + await page.screenshot({ + path: `${out}/${lang}-${theme}-${panelId}-${width}.png`, + }); + } + } + } + const disposed = await page.evaluate(() => { + const owner = window.__godsEyeView.styleManager._cctvControls; + const badge = document.getElementById('cctv-quality-chip'); + const text = badge.textContent; + owner.destroy(); + owner._renderCctvState(null); + return badge.textContent === text; + }); + check(`${lang}: disposal prevents late state painting`, disposed); + } + check('no uncaught browser errors', errors.length === 0, errors); +} finally { + await browser.close(); +} +console.log(`${checks} checks; ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index f8d50f85f..0dd841acf 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1,29 +1,19 @@ #!/usr/bin/env node /** - * Browser QA for the Street Level layer against a running server. Run with + * Browser QA for the Street Level layer against a running server: * `npm run qa:street-level -- --url http://localhost:4173`. * - * `--fixtures` (the CI mode) answers every *.mapillary.com request from - * fixtures, so nothing reaches Mapillary. The server still needs a token - * because the bundle carries it; CI uses a dummy one. Fixture runs also stage - * a rejected key and a keyless second server. - * - * `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry` - * (or QA_FAIL_ON_RETRY=1) fails a missed header press unless the panel moved. - * A failed run saves evidence under qa-artifacts/street-level/. + * `--fixtures` (CI) answers every *.mapillary.com, KartaView and Panoramax + * request and the coverage tile route from fixtures, so nothing reaches those + * providers; the server still needs a (dummy) Mapillary token because the + * bundle carries it. `--strict` fails on any + * skip other than "fixtures only". A failed run saves evidence under + * qa-artifacts/street-level/. */ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; -import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js'; import { tileBounds } from '../src/layers/streetLevel/tileMath.js'; import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs'; -import { COVERAGE_MOVE_DEBOUNCE_MS } from '../src/layers/streetLevel/providers/mapillary/policy.js'; -import { - describePress, - dockPanelByDoubleClick, - liftPanelByHeader, - waitForStill, -} from './qa-panelDrag.mjs'; import { hookRenderErrors, readRenderErrors, @@ -37,266 +27,188 @@ import { PHOTO_SEQUENCE_ID, photoImages, } from './fixtures/street-level/mapillaryGraph.mjs'; +import { + answerKartaViewRequest, + describeKartaViewCall, + KV_SEQUENCE_ID, + kartaViewPhotos, + resetKartaViewFixture, +} from './fixtures/street-level/kartaView.mjs'; +import { + answerPanoramaxRequest, + describePanoramaxCall, + PNX_PARK, + panoramaxPictures, + resetPanoramaxFixture, +} from './fixtures/street-level/panoramax.mjs'; -const PANEL_ID = 'street-level-panel'; - -/** Where a failed run leaves its screenshots and logs. */ -export const ARTIFACT_DIR = 'qa-artifacts/street-level'; - -export const VIEWPORTS = Object.freeze([ - { width: 1440, height: 900 }, - { width: 1280, height: 800 }, -]); - -/** Provider chips the panel must show, in order. */ -export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); - -/** - * Skips no setup can avoid: CI forks have no Google key, and live runs cannot - * stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy - * token runs the keyed flow, so a keyless strict run means that wiring broke. - */ -export const STRICT_ALLOWED_SKIPS = Object.freeze([ - 'no Google 3D', - 'fixtures only', -]); - -export function strictViolations(skips) { - return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason)); -} - -const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find( - (entry) => entry.id === 'street-level', -).token; - -/** The Street Level panel's token in the share link's `ui` panel state. */ -export const PANEL_UI_TOKEN = 't'; - -/** Right-rail order once the layout controller has run. */ -export const RAIL_ORDER = Object.freeze([ - 'pp-toggles', - 'cctv-panel', - 'weather-panel', - 'recent-imagery-panel', - 'street-level-panel', - 'global-context-panel', -]); - -/** Over downtown Sacramento, looking north and down. */ -export const PARK = Object.freeze({ - lon: -121.4944, - lat: 38.5816, - height: 900, - pitch: -1.3, -}); - -export function isCollapsed(classList) { - return [...classList].includes('collapsed'); -} - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - +const VIEWPORT = { width: 1440, height: 900 }; +/** Over downtown Sacramento, above the fixture photo line, looking down. */ +const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 }; const DAY_MS = 86_400_000; /** - * Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old - * sequences plus the photo sequence (PHOTO_LINE), or a few overview points. - * @returns {Uint8Array} empty for zooms that carry nothing + * Coverage for any street-zoom tile: a grid of 360°/flat, recent/old + * sequences, plus the photo sequence where PHOTO_LINE crosses the tile. + * @returns {Uint8Array} empty below z11, which the layer never asks for */ -export function fixtureTile(z, x, y, now = Date.now()) { - const tile = { x, y, z }; +function fixtureTile(z, x, y, now = Date.now()) { + if (z < 11) return new Uint8Array(0); const { west, east, south, north } = tileBounds(x, y, z); const at = (t, lo, hi) => lo + (hi - lo) * t; - if (z <= 5) - return encodeCoverageTile(tile, { - overview: [0.25, 0.5, 0.75].map((t, i) => ({ - id: `fx-${z}-${x}-${y}-o${i}`, - lon: at(t, west, east), - lat: at(t, south, north), - isPano: i === 1, - capturedAt: now - 30 * DAY_MS, - })), - }); - if (z < 11) return new Uint8Array(0); const margin = (east - west) * 0.05; const sequences = []; for (let i = 1; i <= 4; i++) { const t = i / 5; const old = i > 2; - sequences.push({ - id: `fx-${z}-${x}-${y}-h${i}`, - isPano: i % 2 === 0, - capturedAt: now - (old ? 1100 : 30) * DAY_MS, - parts: [ - [ - [west + margin, at(t, south, north)], - [east - margin, at(t, south, north)], + const lat = at(t, south, north); + const lon = at(t, west, east); + sequences.push( + { + id: `fx-${z}-${x}-${y}-h${i}`, + isPano: i % 2 === 0, + capturedAt: now - (old ? 1100 : 30) * DAY_MS, + parts: [ + [ + [west + margin, lat], + [east - margin, lat], + ], ], - ], - }); - sequences.push({ - id: `fx-${z}-${x}-${y}-v${i}`, - isPano: i % 2 === 1, - capturedAt: now - (old ? 30 : 1100) * DAY_MS, - parts: [ - [ - [at(t, west, east), south + margin], - [at(t, west, east), north - margin], + }, + { + id: `fx-${z}-${x}-${y}-v${i}`, + isPano: i % 2 === 1, + capturedAt: now - (old ? 30 : 1100) * DAY_MS, + parts: [ + [ + [lon, south + margin], + [lon, north - margin], + ], ], - ], - }); + }, + ); } - const photoSouth = Math.max(PHOTO_LINE.south, south); - const photoNorth = Math.min(PHOTO_LINE.north, north); - if ( - PHOTO_LINE.lon >= west && - PHOTO_LINE.lon < east && - photoSouth < photoNorth - ) + const lineSouth = Math.max(PHOTO_LINE.south, south); + const lineNorth = Math.min(PHOTO_LINE.north, north); + if (PHOTO_LINE.lon >= west && PHOTO_LINE.lon < east && lineSouth < lineNorth) sequences.push({ id: PHOTO_SEQUENCE_ID, isPano: false, capturedAt: now - 30 * DAY_MS, parts: [ [ - [PHOTO_LINE.lon, photoSouth], - [PHOTO_LINE.lon, photoNorth], + [PHOTO_LINE.lon, lineSouth], + [PHOTO_LINE.lon, lineNorth], ], ], }); - return encodeCoverageTile(tile, { sequences }); + return encodeCoverageTile({ x, y, z }, { sequences }); } -/** - * Ask the real status route from Node, outside the page's interception, so a - * server that never registered it fails even in fixture runs. Node sends no - * Origin, so the same-site gate admits it. - * @returns {Promise<{configured: boolean}>} - */ -export async function assertRealStatusRoute(url, fetchImpl = fetch) { - const route = new URL('/api/mapillary/status', url).href; - let response; - try { - response = await fetchImpl(route, { - headers: { Accept: 'application/json' }, - }); - } catch (error) { - throw new Error(`${route} is unreachable: ${error?.message || error}`, { - cause: error, - }); - } - const type = response.headers.get('content-type') || ''; - const text = await response.text(); - assert.equal( - response.status, - 200, - `${route} must be the server's Mapillary route (HTTP ${response.status}: ${text.slice(0, 120)})`, - ); - assert.match(type, /application\/json/, `${route} answers JSON, not ${type}`); - let body; - try { - body = JSON.parse(text); - } catch { - assert.fail(`${route} answered unparseable JSON: ${text.slice(0, 120)}`); - } - assert.equal( - typeof body?.configured, - 'boolean', - `${route} reports a boolean \`configured\`: ${text.slice(0, 120)}`, - ); - return body; -} +/** Fixture answers and call names per keyless provider host. */ +const KEYLESS_HOSTS = [ + { + host: /(^|\.)(kartaview|openstreetcam)\.org$/, + describe: describeKartaViewCall, + answer: answerKartaViewRequest, + }, + { + host: /^(api\.panoramax\.xyz|panoramax\.openstreetmap\.fr)$/, + describe: describePanoramaxCall, + answer: answerPanoramaxRequest, + }, +]; /** - * Answer the page's Mapillary requests from `fixture`, which steps may change - * mid-run. `configured: undefined` lets the server's real status route answer. - * @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array, calls: Set, unknown: Array}} fixture + * Answer the page's Mapillary, KartaView and Panoramax requests from + * `fixture`, which steps may change mid-run. `configured: undefined` lets the + * server's real status route answer. */ -export async function serveFixtures(page, fixture) { - // CDP Fetch on just these URLs: Puppeteer's page-wide interception stalls - // Cesium's worker scripts, leaving nothing to click. +async function serveFixtures(page, fixture) { + // CDP Fetch on just these URLs: page-wide interception stalls Cesium workers. const cdp = await page.createCDPSession(); await cdp.send('Fetch.enable', { patterns: [ { urlPattern: '*://*.mapillary.com/*' }, { urlPattern: '*/api/mapillary/*' }, + { urlPattern: '*://api.kartaview.org/*' }, + { urlPattern: '*://cdn.kartaview.org/*' }, + { urlPattern: '*://*.openstreetcam.org/*' }, + { urlPattern: '*://api.panoramax.xyz/*' }, + { urlPattern: '*://panoramax.openstreetmap.fr/*' }, ], }); - const fulfil = (requestId, { status, headers = {}, contentType, body }) => - cdp.send('Fetch.fulfillRequest', { - requestId, - responseCode: status, - responseHeaders: Object.entries({ - ...headers, - ...(contentType ? { 'Content-Type': contentType } : {}), - }).map(([name, value]) => ({ name, value: String(value) })), - body: Buffer.from(body ?? '').toString('base64'), - }); const json = (status, payload) => ({ status, contentType: 'application/json', body: JSON.stringify(payload), }); - cdp.on('Fetch.requestPaused', ({ requestId, request }) => { - const address = new URL(request.url); - const method = request.method; - let answer = null; + const answerFor = (method, address) => { if (/(^|\.)mapillary\.com$/.test(address.hostname)) { const call = describeCall(method, address); - answer = answerMapillaryRequest({ + const answer = answerMapillaryRequest({ method, - url: request.url, + url: address.href, images: fixture.images, }); fixture.calls.add(call); if (!answer.known) fixture.unknown.push(call); - } else if ( - address.pathname === '/api/mapillary/status' && - fixture.configured !== undefined - ) { - answer = json(200, { configured: fixture.configured }); - } else { - const tile = address.pathname.match( - /^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/, - ); - if (tile) { - fixture.tileRequests++; - if (fixture.configured === false) - answer = json(503, { error: 'no_key', keyRequired: true }); - else if (fixture.tiles === 'rejected') - answer = json(403, { - error: 'Mapillary rejected the access token', - keyRejected: true, - }); - else { - const bytes = fixtureTile( - Number(tile[1]), - Number(tile[2]), - Number(tile[3]), - ); - answer = bytes.length - ? { - status: 200, - contentType: 'application/x-protobuf', - body: Buffer.from(bytes), - } - : { status: 204, body: '' }; - } - } + return answer; } + const keyless = KEYLESS_HOSTS.find(({ host }) => + host.test(address.hostname), + ); + if (keyless) { + const call = keyless.describe(method, address); + const answer = keyless.answer({ method, url: address.href }); + fixture.calls.add(call); + if (!answer.known) fixture.unknown.push(call); + return answer; + } + if (address.pathname === '/api/mapillary/status') + return fixture.configured === undefined + ? null + : json(200, { configured: fixture.configured }); + const tile = address.pathname.match( + /^\/api\/mapillary\/tiles\/coverage\/(\d+)\/(\d+)\/(\d+)$/, + ); + if (!tile) return null; + if (fixture.configured === false) + return json(503, { error: 'no_key', keyRequired: true }); + if (fixture.rejected) + return json(403, { + error: 'Mapillary rejected the access token', + keyRejected: true, + }); + const bytes = fixtureTile(...tile.slice(1).map(Number)); + return bytes.length + ? { status: 200, contentType: 'application/x-protobuf', body: bytes } + : { status: 204, body: '' }; + }; + cdp.on('Fetch.requestPaused', ({ requestId, request }) => { + const answer = answerFor(request.method, new URL(request.url)); const done = answer - ? fulfil(requestId, answer) + ? cdp.send('Fetch.fulfillRequest', { + requestId, + responseCode: answer.status, + responseHeaders: Object.entries({ + ...answer.headers, + ...(answer.contentType + ? { 'Content-Type': answer.contentType } + : {}), + }).map(([name, value]) => ({ name, value: String(value) })), + body: Buffer.from(answer.body ?? '').toString('base64'), + }) : cdp.send('Fetch.continueRequest', { requestId }); // A page that navigated or closed meanwhile has no request to answer. done.catch(() => {}); }); } -export function newFixture(overrides = {}) { +function newFixture(overrides = {}) { return { configured: undefined, - tiles: 'ok', - tileRequests: 0, + rejected: false, images: photoImages(), calls: new Set(), unknown: [], @@ -304,32 +216,15 @@ export function newFixture(overrides = {}) { }; } -/** Wait for `fn` in the page; false (not a throw) when it does not come true. */ -async function settle(page, fn, arg, { timeout = 10_000 } = {}) { - try { - await page.waitForFunction(fn, { timeout, polling: 100 }, arg); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -async function clearFirstRun(page) { +/** Load the app and dismiss the first-run dialog. */ +async function boot(page, url) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), { timeout: 150_000, }); await page.evaluate(() => document.querySelector('.first-run-explore')?.click(), ); - await settle( - page, - () => !document.querySelector('.first-run-explore'), - null, - { - timeout: 3_000, - }, - ); await page.keyboard.press('Escape'); await page.evaluate(() => { for (const el of document.querySelectorAll( @@ -339,186 +234,160 @@ async function clearFirstRun(page) { }); } +/** Run `test(uiState, arg)` in the page until it is true. */ +const uiUntil = (page, test, arg = null, timeout = 30_000) => + page.waitForFunction( + `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, + { timeout, polling: 100 }, + ); + +/** `id` is the one provider lit. */ +const litAlone = (u, id) => + u.providers.find((p) => p.id === id)?.on === true && + u.providers.filter((p) => p.on).length === 1; + +const getUI = (page) => + page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(), + ); + +const statusIs = (page, text) => + page.waitForFunction( + (want) => document.getElementById('sl-status').textContent === want, + { timeout: 30_000 }, + text, + ); + +const waitForCoverage = (page) => + uiUntil( + page, + (u) => + u.enabled && + u.coverage.count > 0 && + !u.coverage.loading && + !u.keyRequired, + null, + 90_000, + ); + /** - * Click a panel control once the panel holds still and the control is what - * lies under its centre: the rail animates panel heights, so a point read - * once can miss by the time the pointer goes down. + * Press a panel control through the DOM: the rail animates panel heights, so + * a pointer aimed from one frame's layout can miss in the next. */ -async function clickPanelControl(page, selector, { timeout = 5_000 } = {}) { - await waitForStill(page, PANEL_ID); - const deadline = Date.now() + timeout; - for (;;) { - const hit = await page.$eval(selector, (el) => { - el.scrollIntoView({ block: 'nearest' }); - const r = el.getBoundingClientRect(); - const x = r.left + r.width / 2; - const y = r.top + r.height / 2; - const top = document.elementFromPoint(x, y); - return { - x, - y, - ok: Boolean(top && (top === el || el.contains(top))), - target: top - ? `${top.tagName.toLowerCase()}${top.id ? `#${top.id}` : ''}` - : 'nothing', - }; - }); - if (hit.ok) return page.mouse.click(hit.x, hit.y); - if (Date.now() > deadline) - throw new Error(`${selector} is covered by ${hit.target}`); - await new Promise((resolve) => setTimeout(resolve, 200)); - } -} +const press = (page, selector) => + page.$eval(selector, (control) => control.click()); -/** Load the app and clear the first-run dialog. */ -export async function boot(page, url) { - await page.goto(url, { waitUntil: 'domcontentloaded' }); - await clearFirstRun(page); -} - -function readPanel(page) { - return page.evaluate(() => { - const el = document.getElementById('street-level-panel'); - const rail = document.getElementById('right-context-rail'); - const box = el.getBoundingClientRect(); - return { - order: rail ? [...rail.children].map((child) => child.id) : [], - classes: [...el.classList], - width: Math.round(box.width), - right: Math.round(box.right), - status: document.getElementById('sl-status').textContent, - controlsDisabled: document.getElementById('sl-controls').disabled, - bodyDisplay: getComputedStyle(document.getElementById('sl-body')).display, - }; - }); -} - -/** Wait until Street Level is on, drawn, settled and not key-gated. */ -function waitForCoverage(page, { timeout = 90_000 } = {}) { - return page.waitForFunction( - () => { - const dm = window.__godsEyeView.dataManager; - const u = dm.layers.get('street-level').module.getUIState(); - return ( - dm.isEnabled('street-level') && - u.coverage.count > 0 && - !u.coverage.loading && - !u.keyRequired - ); - }, - { timeout }, +/** Expand the strip; a panel state stored by an earlier page may have done so. */ +async function expandPanel(page) { + const collapsed = await page.$eval('#street-level-panel', (node) => + node.classList.contains('collapsed'), + ); + if (collapsed) + await press( + page, + '.panel-collapse-btn[data-collapse-target="street-level-panel"]', + ); + await page.waitForFunction( + () => + getComputedStyle(document.getElementById('sl-body')).display === 'flex', + { timeout: 10_000 }, ); } -async function assertKeylessGate(page) { +async function assertKeyRequired(page) { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('street-level', true, { origin: 'user', }), ); - await page.waitForFunction( - () => document.getElementById('sl-status').textContent === 'KEY REQUIRED', - { timeout: 30_000 }, - ); - const info = await readPanel(page); - assert.equal(info.controlsDisabled, true); - assert.equal(info.status, 'KEY REQUIRED'); - const chip = await page.$eval( - '#sl-provider-chips [data-chip-id="mapillary"]', - (node) => ({ - error: node.classList.contains('chip-error'), - title: node.title, - }), - ); - assert.equal(chip.error, true, 'the keyless provider chip reads as an error'); - assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/); + await statusIs(page, 'KEY REQUIRED'); + const shown = await page.evaluate(() => ({ + disabled: document.getElementById('sl-controls').disabled, + hint: document.getElementById('sl-error').hidden + ? '' + : document.getElementById('sl-error').textContent, + })); + assert.equal(shown.disabled, true, 'controls are gated'); + assert.match(shown.hint, /MAPILLARY_CLIENT_TOKEN/, 'names the key to add'); } /** - * Runs in the page. Find a point where a real click picks `prefix` + `id` (any - * id when null), searched around `points` ([lon, lat]), the aimed billboards, - * or else a grid mid-screen. Only points where the canvas is topmost count. - * @returns {{x: number, y: number, id: string}|null} client coordinates + * Client coordinates of the first thing drawn whose pick id starts with + * `prefix`, scanning `box` ({x, y, w, h} in canvas pixels; default: a row + * across the middle) every `step` px; null when nothing matches. */ -export function findPickPoint({ - prefix, - id, - points, - billboardIds, - billboardPrefix = null, -}) { - const viewer = window.__godsEyeView.viewer; - const scene = viewer.scene; - const canvas = scene.canvas; - const rect = canvas.getBoundingClientRect(); - const Cartographic = viewer.camera.positionCartographic.constructor; - let Cartesian2 = null; - const windowPoint = (x, y) => (Cartesian2 ? new Cartesian2(x, y) : { x, y }); - const wanted = (picked) => { - const pid = - typeof picked?.id === 'string' ? picked.id : (picked?.id?.id ?? null); - if (typeof pid !== 'string' || !pid.startsWith(prefix)) return null; - const rest = pid.slice(prefix.length).split('~')[0]; - return id === null || rest === id ? rest : null; - }; - const tryAt = (x, y) => { - if (x < 2 || y < 2 || x > rect.width - 2 || y > rect.height - 2) - return null; - const top = document.elementFromPoint(rect.left + x, rect.top + y); - if (top !== canvas) return null; - const hit = wanted(scene.pick(windowPoint(x, y))); - return hit ? { x: rect.left + x, y: rect.top + y, id: hit } : null; - }; - const centres = []; - const aimed = (billboardId) => - typeof billboardId === 'string' && - ((billboardIds || []).includes(billboardId) || - (billboardPrefix !== null && billboardId.startsWith(billboardPrefix))); - if ((billboardIds && billboardIds.length) || billboardPrefix !== null) { - for (let i = 0; i < scene.primitives.length; i++) { - const collection = scene.primitives.get(i); - if (typeof collection?.get !== 'function' || !collection.length) continue; - for (let j = 0; j < collection.length; j++) { - const billboard = collection.get(j); - if (!aimed(billboard?.id) || !billboard.show) continue; - const at = billboard.computeScreenSpacePosition?.(scene); - if (at) { - Cartesian2 ??= at.constructor; - centres.push([at.x, at.y]); +const findPick = (page, prefix, { box = null, step = 3 } = {}) => + page.evaluate( + (want, area, stride) => { + const { scene } = window.__godsEyeView.viewer; + const rect = scene.canvas.getBoundingClientRect(); + const { x, y, w, h } = area || { + x: 0, + y: Math.round(rect.height / 2), + w: rect.width, + h: 1, + }; + for (let py = y; py < y + h; py += stride) + for (let px = x; px < x + w; px += stride) { + const id = scene.pick({ x: px, y: py })?.id; + if (typeof id === 'string' && id.startsWith(want)) + return { + x: rect.left + px, + y: rect.top + py, + local: { x: px, y: py }, + id, + }; } - } - } + return null; + }, + prefix, + box, + step, + ); + +/** + * `findPick`, retried until a hit or `timeout`: draped lines build in a + * worker after their tile decoded, so a scan right after LOADING clears can + * come too early on a slow machine. + */ +async function findPickUntil( + page, + prefix, + { timeout = 30_000, box = null, step } = {}, +) { + const deadline = Date.now() + timeout; + for (;;) { + const hit = await findPick(page, prefix, { box, step }); + if (hit || Date.now() >= deadline) return hit; + await new Promise((resolve) => setTimeout(resolve, 500)); } - for (const [lon, lat] of points || []) { - const at = scene.cartesianToCanvasCoordinates( - scene.globe.ellipsoid.cartographicToCartesian( - Cartographic.fromDegrees(lon, lat, 0), - ), - ); - if (at) { - Cartesian2 ??= at.constructor; - centres.push([at.x, at.y]); - } +} + +/** Park the camera at `at`; a late startup flight can move it. */ +async function park(page, at = PARK) { + for (let attempt = 0; attempt < 5; attempt++) { + const stayed = await page.evaluate(async (at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const c = v.camera.positionCartographic; + return ( + Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && + Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 + ); + }, at); + if (stayed) return; } - for (const [cx, cy] of centres) - for (let r = 0; r <= 16; r += 2) - for (const [dx, dy] of [ - [0, 0], - [r, 0], - [-r, 0], - [0, r], - [0, -r], - ]) { - const hit = tryAt(cx + dx, cy + dy); - if (hit) return hit; - } - if (points || billboardIds) return null; - for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) - for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) { - const hit = tryAt(x, y); - if (hit) return hit; - } - return null; + throw new Error(`the camera would not stay parked at ${JSON.stringify(at)}`); } async function main() { @@ -528,39 +397,35 @@ async function main() { const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const fixtures = args.includes('--fixtures'); const strict = args.includes('--strict'); - if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; const browser = await puppeteer.launch({ headless: true, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), - defaultViewport: VIEWPORTS[0], + defaultViewport: VIEWPORT, protocolTimeout: 300_000, args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], }); let count = 0; const skips = []; - /** `fn` gets `skip(reason)`: the step runs what it can but reports a skip. */ - const step = async (label, fn) => { - let skipped = null; - const result = await fn({ - skip: (reason) => { - skipped = reason; - }, - }); - count++; - if (skipped) { - skips.push({ reason: skipped, label }); - console.log(`skip (${skipped}) ${count} ${label}`); - } else console.log(`ok ${count} ${label}`); - return result; + const skip = (reason, label) => { + skips.push(reason); + console.log(`skip (${reason}) ${++count} ${label}`); }; - /** A step this mode cannot run at all. */ - const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason)); + // `fn` may call `skipped(reason)` when a live run has nothing to test. + const step = async (label, fn) => { + let reason = null; + await fn({ skipped: (why) => (reason = why) }); + if (reason) skip(reason, label); + else console.log(`ok ${++count} ${label}`); + }; + const fixtureStep = (label, fn) => + fixtures ? step(label, fn) : skip('fixtures only', label); const errors = []; const monitors = []; - /** The layer only warns on listener exceptions, so count those as errors. */ - const watch = async (page, name) => { + // The layer only warns on listener exceptions, so count those as errors. + const open = async (name, fixture) => { + const page = await browser.newPage(); monitors.push( watchPage(page, { name, @@ -569,167 +434,109 @@ async function main() { }), ); await hookRenderErrors(page); - }; - const renderErrorsSeen = []; - try { - await step( - 'the server registers the real Mapillary status route', - async () => { - const real = await assertRealStatusRoute(url); - console.log(` (server status: configured=${real.configured})`); - if (fixtures) - assert.equal( - real.configured, - true, - 'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy, non-secret one): the browser bundle carries the token, and without it no photo can open', - ); - }, - ); - const page = await browser.newPage(); - const fixture = newFixture(); - await watch(page, 'main'); - if (fixtures) await serveFixtures(page, fixture); + if (fixture) await serveFixtures(page, fixture); await boot(page, url); - const panel = () => readPanel(page); - const status = await page.evaluate(() => - fetch('/api/mapillary/status').then((res) => res.json()), - ); - for (const viewport of VIEWPORTS) { - await page.setViewport(viewport); - await step( - `panel is a collapsed right-rail strip at ${viewport.width}×${viewport.height}`, - async () => { - await settle( - page, - (width) => { - const box = document - .getElementById('street-level-panel') - .getBoundingClientRect(); - return box.width <= 200 && box.right <= width; - }, - viewport.width, - ); - const info = await panel(); - assert.deepEqual(info.order, RAIL_ORDER); - assert.ok(isCollapsed(info.classes)); - assert.equal(info.bodyDisplay, 'none'); - assert.ok(info.width <= 200 && info.right <= viewport.width); - }, - ); - } - await page.setViewport(VIEWPORTS[0]); - const expandStrip = async (target = page) => { - await target.click( - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - ); - assert.ok( - await settle( - target, - () => - !document - .getElementById('street-level-panel') - .classList.contains('collapsed') && - getComputedStyle(document.getElementById('sl-body')).display === - 'flex', - ), - 'Street Level panel did not expand before the timeout', - ); - }; - await expandStrip(); - await step( - 'expanding the strip shows the body, one chip per provider and the legend', - async () => { - const info = await panel(); - assert.ok(!isCollapsed(info.classes)); - assert.equal(info.bodyDisplay, 'flex'); - const chips = await page.evaluate(() => - [ - ...document.querySelectorAll( - '#sl-provider-chips .data-toggle-chip', - ), - ].map((chip) => chip.dataset.chipId), - ); - assert.deepEqual(chips, EXPECTED_PROVIDERS); - // One swatch per source plus "Selected"; each chip wears its colour. - assert.equal( - await page.evaluate( - () => document.querySelectorAll('#sl-legend li').length, - ), - EXPECTED_PROVIDERS.length + 1, - ); - assert.equal( - await page.$eval('[data-chip-id="mapillary"]', (chip) => - chip.style.getPropertyValue('--chip-color'), - ), - '#05cb63', - ); - }, - ); - if (!status.configured) { - await step( - 'keyless install gates the controls and reports KEY REQUIRED', - () => assertKeylessGate(page), - ); - await skipStep( - 'no Mapillary key', - 'the keyed steps (coverage, filters, photos, panel window)', - ); - console.log( - 'keyless run complete (no MAPILLARY_CLIENT_TOKEN on the server)', - ); - } else { - await keyedSteps(); - } - await step('no page errors and no Cesium render-loop errors', async () => { - renderErrorsSeen.push( - ...(await readRenderErrors(page)).map( - (text) => `[main] scene.renderError: ${text}`, - ), - ); - assert.deepEqual([...errors, ...renderErrorsSeen], []); - }); - if (strict) - await step('strict: no skips beyond the documented ones', () => { - assert.deepEqual( - strictViolations(skips), - [], - `--strict accepts only skips for: ${STRICT_ALLOWED_SKIPS.join(', ')}`, - ); - }); - console.log( - `${count} steps: ${count - skips.length} ok, ${skips.length} skipped`, + return page; + }; + const collectRenderErrors = async (page, name) => + errors.push( + ...(await readRenderErrors(page)).map( + (text) => `[${name}] scene.renderError: ${text}`, + ), ); - async function keyedSteps() { - // A late startup flight only shows itself by moving the camera, so park - // and check it stays put rather than reading a state. - const park = async () => { - for (let attempt = 0; attempt < 6; attempt++) { - await page.evaluate((at) => { - const v = window.__godsEyeView.viewer; - v.camera.cancelFlight?.(); - const C = v.camera.positionCartographic.constructor; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(at.lon, at.lat, at.height), - ), - orientation: { heading: 0, pitch: at.pitch, roll: 0 }, - }); - }, PARK); - await sleep(1500); - const stable = await page.evaluate((at) => { - const c = window.__godsEyeView.viewer.camera.positionCartographic; - return ( - Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 && - Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001 - ); - }, PARK); - if (stable) return; - } - }; - await park(); + /** + * A keyless provider: lighting its chip darkens the others and draws its + * coverage with its credit; a click on a line opens a photo from it; × + * closes it. Leaves that provider lit. + */ + const keylessProviderStep = ({ + id, + name, + at, + linePrefix, + box, + reset, + check, + }) => + step( + `${name}: its chip draws keyless coverage and its credit, a line opens its photo, × closes it`, + async ({ skipped }) => { + reset(); + await park(page, at); + await press(page, `#sl-provider-chips [data-chip-id="${id}"]`); + await uiUntil(page, litAlone, id); + await waitForCoverage(page); + await page.waitForFunction( + (credit) => document.body.innerHTML.includes(credit), + { timeout: 15_000 }, + `${name} contributors`, + ); + const area = + box === 'column' + ? await page.evaluate(() => { + const { height, width } = + window.__godsEyeView.viewer.scene.canvas.getBoundingClientRect(); + return { x: Math.round(width * 0.3), y: 0, w: 1, h: height }; + }) + : null; + const line = await findPickUntil(page, linePrefix, { box: area }); + if (!line && !fixtures) return skipped(`no ${name} line in view`); + assert.ok(line, `a ${name} line in view`); + await page.mouse.click(line.x, line.y); + await uiUntil( + page, + (u) => (u.street.imageId && !u.street.loading) || u.street.error, + null, + 90_000, + ); + const { street } = await getUI(page); + if (!fixtures && /No street-level imagery/.test(street.error || '')) + return skipped(`no ${name} photo near the line`); + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, id); + assert.equal( + await page.$eval('#sl-viewer-wrap', (node) => node.hidden), + false, + ); + if (fixtures) + check(street, line.id.slice(linePrefix.length).split('~')[0]); + await press(page, '#sl-viewer-close'); + await uiUntil( + page, + (u) => !u.street.open && u.sequence.selectedId === null, + ); + assert.equal( + await page.$eval('#sl-viewer-wrap', (node) => node.hidden), + true, + ); + }, + ); + let page = null; + + try { + const fixture = newFixture(); + page = await open('main', fixtures && fixture); + const { configured } = await page.evaluate(() => + fetch('/api/mapillary/status').then((res) => res.json()), + ); + if (fixtures) + assert.equal( + configured, + true, + 'fixture runs need a server started with MAPILLARY_CLIENT_TOKEN (CI uses a dummy one)', + ); + + await expandPanel(page); + if (!configured) { + await step('keyless server: KEY REQUIRED gates the controls', () => + assertKeyRequired(page), + ); + skip('no Mapillary key', 'the keyed steps'); + } else { + await park(page); await step( - 'enabling draws coverage and registers the on-globe credit', + 'enabling draws coverage and the Mapillary credit', async () => { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('street-level', true, { @@ -737,9 +544,11 @@ async function main() { }), ); await waitForCoverage(page); - const info = await panel(); - assert.equal(info.controlsDisabled, false); - // Cesium paints on-screen credits a frame or two after they register. + await statusIs(page, 'ON'); + assert.equal( + await page.$eval('#sl-controls', (node) => node.disabled), + false, + ); await page.waitForFunction( () => document.body.innerHTML.includes('Mapillary contributors'), @@ -747,1074 +556,240 @@ async function main() { ); }, ); - const ui = () => - page.evaluate(() => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(), - ); - /** `test(u, arg)` runs in the page, so it must be self-contained. */ - const uiUntil = (test, arg = null, options) => - settle( - page, - `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, - undefined, - options, - ); - const chip = '#sl-provider-chips [data-chip-id="mapillary"]'; - const stacks = { - active: () => - page.evaluate(() => - window.__godsEyeView.mapStackController.getActiveId(), - ), - photoreal: () => - page.evaluate(() => - window.__godsEyeView.mapStackController.isStackAvailable( - 'photoreal', + + await fixtureStep( + 'a rejected key reads KEY REJECTED and clears when switched off', + async () => { + fixture.rejected = true; + await press(page, '#sl-status'); + await statusIs(page, 'OFF'); + await press(page, '#sl-status'); + await statusIs(page, 'KEY REJECTED'); + const shown = await page.evaluate(() => ({ + error: document.getElementById('sl-error').textContent, + hidden: document.getElementById('sl-error').hidden, + disabled: document.getElementById('sl-controls').disabled, + })); + assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); + assert.equal(shown.hidden, false); + assert.equal(shown.disabled, true); + await press(page, '#sl-status'); + await statusIs(page, 'OFF'); + assert.equal( + await page.$eval( + '#sl-error', + (node) => node.hidden || !node.textContent, ), - ), - set: async (id) => { - await page.evaluate( - (stackId) => - window.__godsEyeView.mapStackController.setStack(stackId), - id, + true, + 'no stale error while off', ); - await settle( + fixture.rejected = false; + await press(page, '#sl-status'); + await waitForCoverage(page); + await statusIs(page, 'ON'); + }, + ); + + await park(page); + await waitForCoverage(page); + await step( + 'a line selects its sequence, and a cone opens its photo with a caption', + async () => { + // Fixtures answer lookups for the photo sequence only. + const line = await findPickUntil( page, - (stackId) => - window.__godsEyeView.mapStackController.getActiveId() === stackId, - id, + fixtures ? `mly:seq:${PHOTO_SEQUENCE_ID}` : 'mly:seq:', ); - }, - }; - await step( - 'the header ON/OFF pill switches the layer off and on', - async () => { - assert.equal( - await page.$eval('#sl-status', (node) => - [ - node.tagName, - node.textContent, - node.getAttribute('aria-pressed'), - ].join(':'), - ), - 'BUTTON:ON:true', - ); - await clickPanelControl(page, '#sl-status'); - await page.waitForFunction( - () => !window.__godsEyeView.dataManager.isEnabled('street-level'), - { timeout: 15_000 }, - ); - await page.waitForFunction( - () => document.getElementById('sl-status').textContent === 'OFF', - { timeout: 5_000 }, - ); - assert.equal((await ui()).coverage.count, 0); - await clickPanelControl(page, '#sl-status'); - await waitForCoverage(page); - assert.equal( - await page.$eval('#sl-status', (node) => - node.getAttribute('aria-pressed'), - ), - 'true', - ); - }, - ); - await step( - 'the only lit provider chip switches the whole layer off, credit and all', - async () => { - await clickPanelControl(page, chip); - await page.waitForFunction( - () => - !window.__godsEyeView.dataManager.isEnabled('street-level') && - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().coverage.count === 0, - { timeout: 15_000 }, - ); - await page.waitForFunction( - () => - !document.body.innerHTML.includes('Mapillary contributors'), - { timeout: 15_000 }, - ); - assert.equal( - await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), - 'false', - ); - // The provider stays switched on, so the layer comes back with it. - assert.equal((await ui()).providers[0].on, true); - }, - ); - await step( - 'lighting the chip turns the layer back on with coverage', - async () => { - await clickPanelControl(page, chip); - await waitForCoverage(page); - assert.equal( - await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), - 'true', - ); - }, - ); - await step( - 'the 360° filter keeps at most the unfiltered sequence count', - async () => { - const before = (await ui()).coverage.count; - await clickPanelControl(page, '[data-sl-pano="pano"]'); - await uiUntil((u) => u.filter.pano === 'pano' && !u.coverage.loading); - const after = (await ui()).coverage.count; - assert.ok(after <= before, `${after} ≤ ${before}`); - // The fixtures hold flat sequences too, so the filter must drop some. - if (fixtures) assert.ok(after < before, `${after} < ${before}`); - assert.equal((await ui()).filter.pano, 'pano'); - // The click is a user params request, so the share link records it - // under Street Level's share token, whatever the ledger assigned. - await page.waitForFunction( - (option) => - (new URLSearchParams(location.hash.slice(1)).get('lo') || '') - .split('_') - .includes(option), - { timeout: 10_000 }, - `${STREET_LEVEL_TOKEN}.p.p`, - ); - await clickPanelControl(page, '[data-sl-pano="all"]'); + assert.ok(line, 'a sequence line across the middle of the view'); + await page.mouse.click(line.x, line.y); await uiUntil( - (u, n) => u.filter.pano === 'all' && u.coverage.count === n, - before, - ); - assert.equal((await ui()).coverage.count, before); - }, - ); - await step( - 'the SINCE slider narrows coverage and names its cut-off date', - async () => { - const before = (await ui()).coverage.count; - const setStop = (index) => - page.$eval( - '#sl-since', - (input, value) => { - input.value = String(value); - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - }, - index, - ); - await setStop(5); - await uiUntil((u) => u.filter.sinceDays === 365); - const narrowed = await ui(); - assert.equal(narrowed.filter.sinceDays, 365); - if (fixtures) - assert.ok( - narrowed.coverage.count < before, - `fixtures hold older sequences: ${narrowed.coverage.count} < ${before}`, - ); - assert.ok( - narrowed.coverage.count <= before, - `${narrowed.coverage.count} ≤ ${before}`, - ); - await settle(page, () => - /^LAST YEAR/.test( - document.getElementById('sl-since-label').textContent, - ), - ); - assert.match( - await page.$eval('#sl-since-label', (node) => node.textContent), - /^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/, - ); - await setStop(0); - await uiUntil((u) => u.filter.sinceDays === 0); - await settle( page, - () => - document.getElementById('sl-since-label').textContent === - 'ANY DATE', + (u) => u.sequence.selectedId && !u.sequence.loading, ); - assert.equal((await ui()).filter.sinceDays, 0); - assert.equal( - await page.$eval('#sl-since-label', (node) => node.textContent), - 'ANY DATE', - ); - }, - ); - await step( - 'the MapillaryJS viewer loads ahead of the first photo', - async () => { - // Catches a broken dynamic import before the first click would. - await page.waitForSelector( - '#sl-viewer.mapillary-viewer .mapillary-dom', - { timeout: 60_000 }, - ); - }, - ); - const statusText = (text) => - page.waitForFunction( - (expected) => - document.getElementById('sl-status').textContent === expected, - { timeout: 30_000 }, - text, - ); - const panTo = (lon, lat) => - page.evaluate( - (at) => { - const v = window.__godsEyeView.viewer; - const C = v.camera.positionCartographic.constructor; - v.camera.setView({ - destination: v.scene.globe.ellipsoid.cartographicToCartesian( - C.fromDegrees(at.lon, at.lat, 900), - ), - orientation: { heading: 0, pitch: -1.3, roll: 0 }, - }); - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.refreshCoverage(); - }, - { lon, lat }, - ); - /** Wait until no tile request has arrived for `quietMs`. */ - const tileRequestsQuiet = async (quietMs, timeout = 30_000) => { - const start = Date.now(); - let last = fixture.tileRequests; - let since = Date.now(); - while (Date.now() - start < timeout) { - await sleep(100); - if (fixture.tileRequests !== last) { - last = fixture.tileRequests; - since = Date.now(); - } else if (Date.now() - since >= quietMs) return last; - } - return last; - }; - const QUIET_MS = 2 * COVERAGE_MOVE_DEBOUNCE_MS + 200; - if (fixtures) - await step( - 'a key Mapillary rejects reads KEY REJECTED, stops asking (a pan that asks while accepted asks nothing while rejected), and clears when switched off', - async () => { - // Positive control: with the key accepted, the same pan-and-wait - // the rejected half uses does see new tile requests. - await tileRequestsQuiet(QUIET_MS); - const idle = fixture.tileRequests; - await panTo(-121.46, 38.6); - const asked = await tileRequestsQuiet(QUIET_MS); - assert.ok( - asked > idle, - `a pan while accepted requests tiles (${idle} → ${asked})`, - ); - fixture.tiles = 'rejected'; - await clickPanelControl(page, '#sl-status'); - await statusText('OFF'); - await clickPanelControl(page, '#sl-status'); - await statusText('KEY REJECTED'); - const shown = await page.evaluate(() => ({ - error: document.getElementById('sl-error').textContent, - errorHidden: document.getElementById('sl-error').hidden, - controls: document.getElementById('sl-controls').disabled, - chip: document - .querySelector('#sl-provider-chips [data-chip-id="mapillary"]') - .classList.contains('chip-error'), - })); - assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); - assert.equal(shown.errorHidden, false); - assert.equal(shown.controls, true); - assert.equal(shown.chip, true); - // Panning asks for nothing more: the verdict holds for every tile. - const rejectedAt = await tileRequestsQuiet(QUIET_MS); - await panTo(-121.42, 38.63); - const after = await tileRequestsQuiet(QUIET_MS); - assert.equal(after, rejectedAt, 'no tile requests while rejected'); - // Off clears the message; with a good key it comes back as ON. - await clickPanelControl(page, '#sl-status'); - await statusText('OFF'); - assert.equal( - await page.$eval( - '#sl-error', - (node) => node.hidden || !node.textContent, - ), - true, - 'no stale error while the layer is off', - ); - fixture.tiles = 'ok'; - await clickPanelControl(page, '#sl-status'); - await waitForCoverage(page); - await statusText('ON'); - }, - ); - else - await skipStep( - 'fixtures only', - 'a key Mapillary rejects reads KEY REJECTED and stops asking', - ); - - const fixtureIds = new Map( - fixture.images.map((image, index) => [image.id, { ...image, index }]), - ); - const waitForImage = (id = null) => - page.waitForFunction( - (want) => { - const s = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().street; - return ( - (s.imageId && !s.loading && (!want || s.imageId === want)) || - s.error - ); - }, - { timeout: 90_000 }, - id, - ); - const openById = async (id) => { - await page.evaluate((imageId) => { - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openImage('mapillary', imageId); - }, id); - await waitForImage(id); - const street = (await ui()).street; - assert.equal(street.error, null, `viewer error: ${street.error}`); - return street; - }; - - await park(); - await waitForCoverage(page); - await step( - 'a click on a coverage line selects its sequence and draws its cones', - async () => { - const original = await stacks.active(); - // Draped lines on flat imagery: the line is where it is drawn. - await stacks.set('esri-imagery'); - await uiUntil((u) => u.surface === 'draped'); - await waitForCoverage(page); - const target = fixtures ? PHOTO_SEQUENCE_ID : null; - const along = Array.from({ length: 9 }, (_, i) => [ - PHOTO_LINE.lon, - PHOTO_LINE.south + - ((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south), - ]); - // Ground primitives are built over a few frames: poll for a pick. - const handle = await page.waitForFunction( - findPickPoint, - { timeout: 30_000, polling: 250 }, - { - prefix: 'mly:seq:', - id: target, - points: fixtures ? along : null, - billboardIds: null, + // Cones clamp to the ground: on a slow GPU (CI's SwiftShader) they + // can take a few frames after the selection to become pickable. + const cone = await findPickUntil(page, 'mly:img:', { + box: { + x: Math.max(0, line.local.x - 150), + y: Math.max(0, line.local.y - 150), + w: 300, + h: 300, }, - ); - const at = await handle.jsonValue(); - await page.mouse.click(at.x, at.y); - const selected = await uiUntil( - (u, id) => - u.sequence.selectedId === id && - !u.sequence.loading && - u.sequence.images > 0, - at.id, - { timeout: 30_000 }, - ); - const sequence = (await ui()).sequence; - assert.ok( - selected, - `the click at ${Math.round(at.x)},${Math.round(at.y)} on ${at.id} selected it: ${JSON.stringify(sequence)}`, - ); - if (fixtures) - assert.equal(sequence.images, fixture.images.length, 'every photo'); - // The cones are on the globe: one of them picks as an image. - const cone = await page.waitForFunction( - findPickPoint, - { timeout: 15_000, polling: 250 }, - { - prefix: 'mly:img:', - id: null, - points: fixtures - ? fixture.images.map((image) => [image.lon, image.lat]) - : null, - billboardIds: null, - // Live: the cones' positions are only known from the globe. - billboardPrefix: fixtures ? null : 'mly:img:', - }, - ); - assert.ok(await cone.jsonValue(), 'an image cone is pickable'); - await page.evaluate(() => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.clearSequence(), - ); - await uiUntil((u) => u.sequence.selectedId === null); - await stacks.set(original); - }, - ); - - let firstImageId = null; - await park(); - await waitForCoverage(page); - await step( - 'opening the nearest image shows the viewer with a caption', - async () => { - // Fire and forget: the open can outlive one CDP call, so poll instead. - await page.evaluate(() => { - void window.__godsEyeView.dataManager.layers - .get('street-level') - .module.openNearest(); + step: 8, }); - await waitForImage(); - const street = (await ui()).street; - assert.equal(street.error, null, `viewer error: ${street.error}`); - // Some images carry no creator name; the date/bearing side always fills. - const filled = await settle( + assert.ok(cone, 'an image cone near the selected line'); + await page.mouse.click(cone.x, cone.y); + await uiUntil( page, + (u) => (u.street.imageId && !u.street.loading) || u.street.error, + null, + 90_000, + ); + const { street } = await getUI(page); + assert.equal(street.error, null, `viewer error: ${street.error}`); + await page.waitForSelector('#sl-viewer .mapillary-dom', { + timeout: 60_000, + }); + await page.waitForFunction( () => document.getElementById('sl-image-when').textContent.trim() .length > 0, - null, { timeout: 30_000 }, ); - if (!filled) { - const dump = await page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return { - street: u.street, - captionNodes: - document.querySelectorAll('#sl-image-when').length, - captionNow: - document.getElementById('sl-image-when').textContent, - wrapHidden: document.getElementById('sl-viewer-wrap').hidden, - collapsed: document - .getElementById('street-level-panel') - .classList.contains('collapsed'), - }; - }); - throw new Error(`caption never filled: ${JSON.stringify(dump)}`); - } const view = await page.evaluate(() => ({ hidden: document.getElementById('sl-viewer-wrap').hidden, - when: document.getElementById('sl-image-when').textContent.trim(), - link: document.getElementById('sl-image-link').textContent.trim(), - href: document.getElementById('sl-image-link').href, - width: Math.round( - document.getElementById('sl-viewer').getBoundingClientRect() - .width, - ), + width: document.getElementById('sl-viewer').getBoundingClientRect() + .width, + link: document.getElementById('sl-image-link').href, })); assert.equal(view.hidden, false); - assert.ok(view.width > 200); - assert.ok(view.when.length > 0, 'caption shows the capture date'); - assert.equal(street.providerId, 'mapillary'); - firstImageId = street.imageId; - assert.equal(view.link, 'MAPILLARY ↗'); + assert.ok(view.width > 200, `viewer width ${view.width}`); + assert.match(view.link, /mapillary\.com\/app\/\?pKey=/); if (fixtures) { - const known = fixtureIds.get(street.imageId); - assert.ok(known, `a fixture photo opened (${street.imageId})`); - assert.equal(street.isPano, known.isPano, 'pano flag from the ent'); + assert.ok( + fixture.images.some((image) => image.id === street.imageId), + `fixture photo ${street.imageId}`, + ); assert.equal(street.sequenceId, PHOTO_SEQUENCE_ID); } - const fit = await page.evaluate(() => { - const inner = document.querySelector('.street-level-panel-inner'); - const box = inner.getBoundingClientRect(); - const wrap = document - .getElementById('sl-viewer-wrap') - .getBoundingClientRect(); - return { - scrollTop: inner.scrollTop, - top: wrap.top - box.top, - overflowBottom: wrap.bottom - box.bottom, - }; - }); - assert.equal(fit.scrollTop, 0, 'panel not scrolled'); - assert.ok( - fit.top >= 0 && fit.top < 80, - `viewer right under the header (${fit.top}px)`, - ); - assert.ok( - fit.overflowBottom <= 1, - `whole viewer visible (${fit.overflowBottom}px cut)`, - ); - assert.match(view.href, /mapillary\.com\/app\/\?pKey=/); - // The open selects the photo's sequence: its cones are drawn. - assert.ok( - await uiUntil( - (u) => u.sequence.selectedId === u.street.sequenceId, - null, - { timeout: 30_000 }, - ), - 'the open photo’s sequence is selected', - ); - }, - ); - if (fixtures) - await step( - 'a click on a neighbouring cone steps the viewer to that photo (360° and flat)', - async () => { - // Let the framing flight land, so the cone does not move under the click. - await settle( - page, - () => !window.__godsEyeView.viewer.camera._currentFlight, - null, - { timeout: 10_000 }, - ); - await uiUntil((u) => u.sequence.images > 0 && !u.sequence.loading); - const current = fixtureIds.get(firstImageId); - const neighbour = [1, -1, 2, -2, 4, -4] - .map((offset) => fixture.images[current.index + offset]) - .find((image) => image && image.isPano !== current.isPano); - assert.ok(neighbour, 'a neighbour of the other camera type'); - const handle = await page.waitForFunction( - findPickPoint, - { timeout: 15_000, polling: 250 }, - { - prefix: 'mly:img:', - id: neighbour.id, - points: null, - billboardIds: [`mly:img:${neighbour.id}`], - }, - ); - const at = await handle.jsonValue(); - await page.mouse.click(at.x, at.y); - await waitForImage(neighbour.id); - const street = (await ui()).street; - assert.equal(street.error, null); - assert.equal(street.imageId, neighbour.id, 'stepped to the cone'); - assert.equal(street.isPano, neighbour.isPano); - assert.notEqual(street.isPano, current.isPano, 'both kinds shown'); - await openById(firstImageId); - }, - ); - else - await skipStep( - 'fixtures only', - 'a click on a neighbouring cone steps the viewer to that photo', - ); - await step( - 'FOLLOW is offered only on the Google 3D map', - async ({ skip }) => { - const follow = () => - page.$eval('#sl-follow-btn', (node) => ({ - disabled: node.disabled, - pressed: node.getAttribute('aria-pressed'), - title: node.title, - })); - const followIs = (disabled, pressed = null) => - settle( - page, - ([d, p]) => { - const node = document.getElementById('sl-follow-btn'); - return ( - node.disabled === d && - (p === null || node.getAttribute('aria-pressed') === p) - ); - }, - [disabled, pressed], - ); - const original = await stacks.active(); - await stacks.set('esri-imagery'); - await followIs(true); - let state = await follow(); - assert.equal(state.disabled, true, 'disabled on Esri'); - assert.match(state.title, /needs the Google 3D map/); - if (await stacks.photoreal()) { - await stacks.set('photoreal'); - await followIs(false); - state = await follow(); - assert.equal(state.disabled, false, 'enabled on Google 3D'); - await clickPanelControl(page, '#sl-follow-btn'); - await followIs(false, 'true'); - assert.equal((await follow()).pressed, 'true'); - await stacks.set('esri-imagery'); - await followIs(true, 'false'); - state = await follow(); - assert.equal( - state.pressed, - 'false', - 'leaving Google 3D stops following', - ); - assert.equal(state.disabled, true); - } else { - console.log( - ' (Google 3D unavailable here: only the disabled path ran)', - ); - skip('no Google 3D'); - } - await stacks.set(original); - }, - ); - await park(); - await step( - 'closing a photo stops the framing flight toward it', - async () => { - // Opening frames the photo with a 1.6 s camera flight; closing right - // away must cancel it, leaving the camera where it was. - const result = await page.evaluate(async (id) => { - const v = window.__godsEyeView.viewer; - const module = - window.__godsEyeView.dataManager.layers.get( - 'street-level', - ).module; - const opened = await module.openImage('mapillary', id); - const flying = Boolean(v.camera._currentFlight); - const from = v.camera.positionCartographic.clone(); - module.closeViewer(); - const stillFlying = Boolean(v.camera._currentFlight); - const frames = (n) => - new Promise((resolve) => { - const tick = () => - --n <= 0 ? resolve() : requestAnimationFrame(tick); - requestAnimationFrame(tick); - }); - await frames(20); - const to = v.camera.positionCartographic; - return { - opened, - flying, - stillFlying, - moved: Math.hypot( - (to.longitude - from.longitude) * 6_371_000, - (to.latitude - from.latitude) * 6_371_000, - to.height - from.height, - ), - height: to.height, - }; - }, firstImageId); - assert.equal(result.opened, true, 'the photo opened'); - assert.equal(result.flying, true, 'opening started a framing flight'); - assert.equal(result.stillFlying, false, 'closing cancelled it'); - assert.ok( - result.moved < 0.5, - `the camera stopped where it was (moved ${result.moved.toFixed(2)} m)`, - ); - assert.ok( - result.height > PARK.height / 2, - `the camera never reached the photo (${Math.round(result.height)} m up)`, - ); - assert.equal((await ui()).street.open, false); - }, - ); - await step( - 'coverage sits on the bare earth on Google 3D and drapes elsewhere', - async ({ skip }) => { - const surface = () => - page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return { surface: u.surface, count: u.coverage.count }; - }); - const original = await stacks.active(); - await stacks.set('esri-imagery'); - await page.waitForFunction( - () => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().surface === 'draped', - { timeout: 10_000 }, - ); - const coverageLoaded = () => - waitForCoverage(page, { timeout: 60_000 }); - await coverageLoaded(); - assert.equal((await surface()).surface, 'draped', 'draped on Esri'); - if (await stacks.photoreal()) { - await stacks.set('photoreal'); - await page.waitForFunction( - () => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().surface === 'terrain', - { timeout: 15_000 }, - ); - await coverageLoaded(); - assert.equal((await surface()).surface, 'terrain'); - } else { - console.log( - ' (Google 3D unavailable here: only the draped path ran)', - ); - skip('no Google 3D'); - } - await stacks.set(original); - }, - ); - const expanded = () => - page.evaluate(() => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - await step( - 'EXPAND opens a modal dialog (the rest of inert) and Esc returns focus to the button', - async () => { - await openById(firstImageId); - const inertBefore = await page.evaluate( - () => - [...document.body.children].filter((node) => node.inert).length, - ); - await clickPanelControl(page, '#sl-viewer-expand'); - await settle(page, () => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - const dialog = await page.evaluate(() => { - const wrap = document.getElementById('sl-viewer-wrap'); - const others = [...document.body.children].filter( - (node) => node !== wrap, - ); - return { - role: wrap.getAttribute('role'), - modal: wrap.getAttribute('aria-modal'), - inside: wrap.contains(document.activeElement), - width: Math.round(wrap.getBoundingClientRect().width), - onBody: wrap.parentElement === document.body, - notInert: others - .filter((node) => !node.inert) - .map((node) => node.id || node.tagName.toLowerCase()), - wrapInert: wrap.inert, - }; - }); - assert.equal(dialog.role, 'dialog'); - assert.equal(dialog.modal, 'true'); - assert.equal(dialog.inside, true); - assert.ok(dialog.width > 900); - assert.equal(dialog.onBody, true); - assert.deepEqual( - dialog.notInert, - [], - 'every other body child is inert', - ); - assert.equal(dialog.wrapInert, false); - await page.keyboard.press('Escape'); - await settle( - page, - () => - !document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - const after = await page.evaluate(() => ({ - expanded: document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - focus: document.activeElement?.id, - inert: [...document.body.children].filter((node) => node.inert) - .length, - })); - assert.equal( - `${after.expanded}:${after.focus}`, - 'false:sl-viewer-expand', - ); - assert.equal( - after.inert, - inertBefore, - 'the application is live again', - ); - }, - ); - await step( - '× closes the image and deselects it on the globe', - async () => { - await clickPanelControl(page, '#sl-viewer-close'); + // Opening selects the photo's sequence, so its cones are drawn. await uiUntil( - (u) => !u.street.open && u.sequence.selectedId === null, + page, + (u) => u.sequence.selectedId === u.street.sequenceId, ); - const after = await ui(); - assert.equal(after.street.open, false); - assert.equal(after.sequence.selectedId, null); }, ); - const floating = () => - page.$eval('#street-level-panel', (node) => - node.classList.contains('panel-floating'), - ); - const center = (selector) => - page.$eval(selector, (node) => { - const r = node.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }); - // Right after a dock the rail is still animating, so the shared helpers - // re-resolve the header and retry only a press that provably missed it. - const floatPanel = async () => { - const attempt = await liftPanelByHeader(page, PANEL_ID, { - dx: -400, - dy: 80, - }); - assert.equal( - attempt.done, - true, - `a header drag lifts the panel out of the rail (${describePress(attempt)})`, - ); - assert.equal(await floating(), true); - }; + await step( - 'the panel floats on a header drag, resizes, and the viewer takes the room', + 'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open', async () => { - await openById(firstImageId); - await floatPanel(); - const viewerHeight = () => - page.$eval('#sl-viewer', (node) => - Math.round(node.getBoundingClientRect().height), - ); - const before = await viewerHeight(); - const grip = await center('#street-level-panel .panel-resize-grip'); - await page.mouse.move(grip.x, grip.y); - await page.mouse.down(); - await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 }); - await page.mouse.up(); - await settle( - page, - (height) => - document.getElementById('sl-viewer').getBoundingClientRect() - .height > - height + 60, - before, - ); - const after = await viewerHeight(); - assert.ok( - after > before + 60, - `viewer grew with the window (${before} → ${after}px)`, - ); + await page.click('[data-sl-render="fill"]'); + await uiUntil(page, (u) => u.street.renderMode === 'fill'); assert.equal( - await page.$eval( - '.sl-settings', - (node) => getComputedStyle(node).overflowY, + await page.evaluate(() => + localStorage.getItem('gev:street-level:render-mode'), ), - 'auto', - 'only the settings block scrolls', + 'fill', ); - }, - ); - await step( - 'SHRINK after EXPAND docks the window back in the rail at its default size, viewer inside the panel', - async () => { - await clickPanelControl(page, '#sl-viewer-expand'); - await settle(page, () => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - assert.equal(await expanded(), true, 'expanded'); - await clickPanelControl(page, '#sl-viewer-expand'); - await settle( - page, - () => - !document - .getElementById('street-level-panel') - .classList.contains('panel-floating') && - !document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - assert.equal(await floating(), false, 'docked again'); - const style = await page.$eval('#street-level-panel', (node) => ({ - width: node.style.width, - height: node.style.height, - parent: node.parentElement.id, - })); - assert.deepEqual(style, { - width: '', - height: '', - parent: 'right-context-rail', - }); - const home = await page.evaluate(() => { - const wrap = document.getElementById('sl-viewer-wrap'); - return { - inPanel: Boolean(wrap.closest('#street-level-panel')), - onBody: wrap.parentElement === document.body, - }; - }); + await page.keyboard.press('Home'); + await uiUntil(page, (u) => u.street.renderMode === 'letterbox'); assert.deepEqual( - home, - { inPanel: true, onBody: false }, - 'the viewer returned into the panel', + await page.$eval('[data-sl-render="letterbox"]', (node) => ({ + selected: node.getAttribute('aria-checked'), + tabIndex: node.tabIndex, + focused: document.activeElement === node, + })), + { selected: 'true', tabIndex: 0, focused: true }, ); + await page.keyboard.press('End'); + await uiUntil(page, (u) => u.street.renderMode === 'fill'); }, ); - await step('a header double-click docks a floating window', async () => { - await floatPanel(); - const attempt = await dockPanelByDoubleClick(page, PANEL_ID); + + await step('× closes the photo and deselects its sequence', async () => { + await press(page, '#sl-viewer-close'); + await uiUntil( + page, + (u) => !u.street.open && u.sequence.selectedId === null, + ); assert.equal( - attempt.done, + await page.$eval('#sl-viewer-wrap', (node) => node.hidden), true, - `docked again (${describePress(attempt)})`, ); }); + await step( - 'collapsing a floating window docks it as the rail strip', + 'panorama segments keep native arrow and Home navigation', async () => { - // The double-click just docked a panel showing a photo: let the photo - // and its sequence finish loading before pressing the header again. - await uiUntil((u) => !u.street.loading && !u.sequence.loading); - await floatPanel(); - await clickPanelControl( - page, - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - ); - await settle(page, () => { - const node = document.getElementById('street-level-panel'); - return ( - node.classList.contains('collapsed') && - !node.classList.contains('panel-floating') - ); - }); - const state = await page.$eval('#street-level-panel', (node) => ({ - floating: node.classList.contains('panel-floating'), - collapsed: node.classList.contains('collapsed'), - height: node.style.height, - })); - assert.deepEqual(state, { - floating: false, - collapsed: true, - height: '', - }); - }, - ); - await step( - 'on a phone the whole photo fits in the docked panel', - async () => { - // Width alone drives the phone layout; toggling isMobile would reload. - await page.setViewport({ width: 390, height: 844 }); - await settle(page, () => innerWidth === 390); - if ( - await page.$eval('#street-level-panel', (node) => - node.classList.contains('collapsed'), - ) - ) - await expandStrip(); - await openById(firstImageId); - const measure = () => - page.evaluate(() => { - const inner = document - .querySelector('.street-level-panel-inner') - .getBoundingClientRect(); - const meta = document - .getElementById('sl-image-meta') - .getBoundingClientRect(); - const viewer = document - .getElementById('sl-viewer') - .getBoundingClientRect(); - return { - cut: Math.round(meta.bottom - inner.bottom), - top: Math.round(viewer.top - inner.top), - height: Math.round(viewer.height), - overflowX: document.documentElement.scrollWidth > innerWidth, - }; - }); - // The panel settles its phone layout over a frame or two. - await settle(page, () => { - const inner = document - .querySelector('.street-level-panel-inner') - .getBoundingClientRect(); - const meta = document - .getElementById('sl-image-meta') - .getBoundingClientRect(); - return ( - meta.bottom - inner.bottom <= 1 && - document.getElementById('sl-viewer').getBoundingClientRect() - .height >= 100 - ); - }); - const fit = await measure(); - assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); - assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); - assert.equal(fit.overflowX, false, 'no sideways scroll'); - await page.setViewport(VIEWPORTS[0]); - await settle( - page, - (width) => innerWidth === width, - VIEWPORTS[0].width, - ); - }, - ); - await step( - 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', - async () => { - await page.evaluate(() => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.closeViewer(), - ); - // Press through the DOM: a toast over the button must not make this - // step about pointer aim. - await page.$eval( - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - (button) => { - const panel = document.getElementById('street-level-panel'); - if (!panel.classList.contains('collapsed')) button.click(); - }, - ); - assert.ok( - await settle(page, () => - document - .getElementById('street-level-panel') - .classList.contains('collapsed'), - ), - 'the panel collapsed', - ); - const shared = (collapsed) => - settle( - page, - (assignment) => - (new URLSearchParams(location.hash.slice(1)).get('ui') || '') - .split('_') - .includes(assignment), - `${PANEL_UI_TOKEN}.c.${collapsed ? '1' : '0'}`, - ); - assert.ok( - await shared(true), - `the share link records the collapsed panel as ui ${PANEL_UI_TOKEN}.c.1: ${await page.evaluate(() => location.hash)}`, - ); - await page.reload({ waitUntil: 'domcontentloaded' }); - await clearFirstRun(page); + await page.click('[data-sl-pano="flat"]'); + await uiUntil(page, (u) => u.filter.pano === 'flat'); + await page.keyboard.press('ArrowRight'); + await uiUntil(page, (u) => u.filter.pano === 'all'); + await page.keyboard.press('End'); + await uiUntil(page, (u) => u.filter.pano === 'flat'); + await page.keyboard.press('Home'); + await uiUntil(page, (u) => u.filter.pano === 'all'); await waitForCoverage(page); - // The panel would open on the enable transition, before coverage. - const after = await page.evaluate(() => ({ - enabled: window.__godsEyeView.dataManager.isEnabled('street-level'), - collapsed: document - .getElementById('street-level-panel') - .classList.contains('collapsed'), - })); - assert.deepEqual( - after, - { enabled: true, collapsed: true }, - 'the restored layer did not reopen the panel', - ); - assert.ok(await shared(true), 'still shared as collapsed'); }, ); - if (fixtures) - await step( - 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED', - async () => { - const keyless = await browser.newPage(); - await keyless.setViewport(VIEWPORTS[0]); - await watch(keyless, 'keyless'); - await serveFixtures(keyless, newFixture({ configured: false })); - await boot(keyless, url); - await expandStrip(keyless); - await assertKeylessGate(keyless); - renderErrorsSeen.push( - ...(await readRenderErrors(keyless)).map( - (text) => `[keyless] scene.renderError: ${text}`, - ), - ); - await keyless.close(); - }, - ); - else - await skipStep( - 'fixtures only', - 'keyless install (a second page) reports KEY REQUIRED', - ); - if (fixtures) - await step( - 'every Mapillary request was answered from a fixture', - () => { - console.log( - ` (answered: ${[...fixture.calls].sort().join(' | ')})`, - ); - assert.deepEqual(fixture.unknown, [], 'no unanswered call'); - }, - ); + + await keylessProviderStep({ + id: 'kartaview', + name: 'KartaView', + at: PARK, + linePrefix: 'kv:line:', + // A row across the middle crosses the drive, west of the old street. + box: null, + reset: resetKartaViewFixture, + check: (street) => { + assert.equal(street.sequenceId, KV_SEQUENCE_ID); + assert.ok( + kartaViewPhotos().some((photo) => photo.id === street.imageId), + `fixture photo ${street.imageId}`, + ); + }, + }); + await keylessProviderStep({ + id: 'panoramax', + name: 'Panoramax', + at: PNX_PARK, + linePrefix: 'pnx:seq:', + // The street runs east-west: scan a column left of the middle. + box: 'column', + reset: resetPanoramaxFixture, + check: (street, lineId) => { + assert.equal(street.sequenceId, lineId, 'the line clicked'); + assert.ok( + panoramaxPictures().some((photo) => photo.id === street.imageId), + `fixture picture ${street.imageId}`, + ); + }, + }); + // Back to Mapillary, where the next page and a reload expect it. + await press(page, '#sl-provider-chips [data-chip-id="mapillary"]'); + await uiUntil(page, litAlone, 'mapillary'); + await park(page); + + await fixtureStep( + 'keyless server (second page): KEY REQUIRED gates the controls', + async () => { + const keyless = await open( + 'keyless', + newFixture({ configured: false }), + ); + await expandPanel(keyless); + await assertKeyRequired(keyless); + await collectRenderErrors(keyless, 'keyless'); + await keyless.close(); + }, + ); + + await fixtureStep( + 'every Mapillary, KartaView and Panoramax request was answered from a fixture', + () => { + console.log(` (answered: ${[...fixture.calls].sort().join(' | ')})`); + assert.deepEqual(fixture.unknown, []); + }, + ); } + + await step('no page errors and no Cesium render-loop errors', async () => { + await collectRenderErrors(page, 'main'); + assert.deepEqual(errors, []); + }); + if (strict) + await step('strict: no skips other than "fixtures only"', () => + assert.deepEqual( + skips.filter((reason) => reason !== 'fixtures only'), + [], + ), + ); + console.log( + `${count} steps: ${count - skips.length} ok, ${skips.length} skipped`, + ); } catch (error) { const dir = await saveFailureArtifacts({ - dir: ARTIFACT_DIR, + dir: 'qa-artifacts/street-level', browser, monitors, error, diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 73d28ca60..b236ab0b1 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -139,15 +139,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index 596d2f009..6ea1c953d 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -80,9 +80,7 @@ try { page.evaluate(() => { const { viewer, dataManager } = window.__godsEyeView; let upper = 0, - lower = 0, - near = 0; - const camera = viewer.camera.positionWC; + lower = 0; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index 44319af0d..d1b4950fd 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -145,15 +145,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 2a04d5595..9770999df 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -25,12 +25,13 @@ * Visual proof saved to qa-shots/ (gitignored). * * Run: node scripts/qa-traffic.mjs --url http://localhost:4410 - * Add --fixtures for synthetic roads and the recorded flow tile without a key; - * that mode does not qualify live source access or quota accounting. + * Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key; + * that mode does not qualify geographic terrain, live source access or quota accounting. * Exits non-zero on any FAIL. Does not commit anything. */ import puppeteer from 'puppeteer'; +import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -44,10 +45,12 @@ const getOpt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; }; -const APP_URL = getOpt('--url', 'http://localhost:4410'); +const appUrl = new URL(getOpt('--url', 'http://localhost:4410')); +appUrl.searchParams.set('welcome', '0'); +const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); -const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; +const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -80,6 +83,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Enable traffic + teleport, then poll the layer until settled. */ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { + // The app deliberately stops rendering hidden pages. Programmatic camera + // changes do not activate a headful window the way a real UI click does. + await page.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'visible'); return page.evaluate(async (v, minC, tS) => { const gev = window.__godsEyeView; const dm = gev.dataManager; @@ -97,6 +104,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { }); let s = null; for (let i = 0; i < tS; i++) { + if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused'); await new Promise((r) => setTimeout(r, 1000)); s = mod.getStats(); // Retained roads from the previous city can satisfy count/loading before @@ -113,17 +121,21 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); - process.exit(2); + process.exitCode = 2; + return; } - const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); - if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); + const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null); + if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(SHOTS_DIR, { recursive: true }); @@ -131,8 +143,10 @@ async function main() { headless: HEADFUL ? false : 'new', ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-web-security', + '--no-sandbox', '--disable-setuid-sandbox', + ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), + '--disable-backgrounding-occluded-windows', + '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], @@ -140,24 +154,22 @@ async function main() { let exitCode = 0; try { - const page = await browser.newPage(); - const onFixture = (request) => { - const response = fixtureResponse(request); - if (response) void request.respond(response); else void request.continue(); - }; - if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); } + let page = await browser.newPage(); + // Keep this visible-page regression deterministic even if the OS starts + // Chrome minimized. Use browser focus emulation, not patched DOM getters. + const focusSession = await page.createCDPSession(); + await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1440, height: 900 }); - // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). + // Track flow-tile requests for the (iii) budget check. const flowRequests = []; page.on('request', (req) => { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); - const trafficLogs = []; - page.on('console', (msg) => { - const t = msg.text(); - if (t.includes('[Data:Traffic]')) trafficLogs.push(t); - }); + const pageErrors = []; + if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -190,7 +202,7 @@ async function main() { `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; await sleep(1200); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── @@ -223,7 +235,7 @@ async function main() { (hid.flowBuckets?.sim || 0) === 0 && hid.count > 0, `count=${hid.count} sim=${hid.flowBuckets?.sim}`); if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL }); } else { record('PARAM: hide mode renders zero sim (white) dots', null, 'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests'); @@ -231,30 +243,26 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); - trafficLogs.length = 0; - await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); + const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { - const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('[')); - let ok = false; let detail = 'no fetch log captured'; - if (fetchLine) { - const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/); - if (m) { - const [s, w, n, e] = m.slice(1).map(Number); - const cLat = (s + n) / 2; const cLon = (w + e) / 2; - const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2); - ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin) - detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`; - } + const bounds = oblique?.roadBounds; + let ok = false; let detail = 'no road bounds published'; + if (bounds) { + const cLat = (bounds.south + bounds.north) / 2; + const cLon = (bounds.west + bounds.east) / 2; + const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180)); + ok = dKm > 1 && dKm <= 13; + detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`; } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL }); } // ── (iii) budget honesty ───────────────────────────────────────────────── console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...'); { - const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()); + const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()); const grew = statusAfter.dailyCount - statusBefore.dailyCount; const ok = grew >= 0 && grew <= flowRequests.length; record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok, @@ -264,25 +272,22 @@ async function main() { // ── (v) KEYLESS fallback (intercepted — server key untouched) ──────────── console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...'); - if (FIXTURES) page.off('request', onFixture); - await page.setRequestInterception(true); + // Keyless is an independent fresh-session contract. Close the keyed page + // so its workers, terrain work and intercepted cache cannot leak into it. + await page.close(); + page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + const keylessFocus = await page.createCDPSession(); + await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); const keylessFlowReqs = []; - page.on('request', (req) => { - const url = req.url(); - if (url.includes('/api/tomtom/status')) { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) }); - return; - } - if (url.includes('/api/tomtom/flow/')) { - keylessFlowReqs.push(url); - req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); - return; - } - const response = fixtureResponse(req); - if (response) { void req.respond(response); return; } - try { req.continue(); } catch { /* already handled */ } + await installTrafficRoutes(page, { + origin: appUrl.origin, fixtures: FIXTURES, keyless: true, + onFlow: url => keylessFlowReqs.push(url), + onError: error => pageErrors.push(error.message), }); - await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, @@ -312,8 +317,9 @@ async function main() { exitCode = 1; } await sleep(1000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } + record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index eb7b59985..23925c743 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) { const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); return { + pageVisibility: document.visibilityState, total: state._vehicles.size, visible: state._visible.size, moving: state._moving.size, @@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) { }; }); console.log(` ${label}: ${JSON.stringify(result)}`); + if (result.pageVisibility !== 'visible') + throw new Error( + `${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`, + ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index b76e14706..70ed1503c 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,6 +1,7 @@ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const BOSTON = { id: 'boston', + feedId: 'mbta', label: 'Boston (MBTA)', lat: 42.3554, lon: -71.0605, @@ -11,6 +12,7 @@ const LIVE_CITIES = [ BOSTON, { id: 'austin', + feedId: 'capmetro-austin', label: 'Austin (CapMetro)', lat: 30.2672, lon: -97.7431, @@ -19,6 +21,7 @@ const LIVE_CITIES = [ }, { id: 'minneapolis', + feedId: 'metrotransit-msp', label: 'Minneapolis (Metro Transit)', lat: 44.9778, lon: -93.265, @@ -27,6 +30,7 @@ const LIVE_CITIES = [ }, { id: 'helsinki', + feedId: 'hsl-helsinki', label: 'Helsinki (HSL)', lat: 60.1699, lon: 24.9384, @@ -35,6 +39,7 @@ const LIVE_CITIES = [ }, { id: 'oslo', + feedId: 'entur-norway', label: 'Oslo (Entur)', lat: 59.9139, lon: 10.7522, diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index a16bab9bc..c229be96c 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -1,11 +1,11 @@ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; /** 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', + executablePath: chromeExecutablePath(), args: ['--no-sandbox', '--disable-background-timer-throttling'], }); try { diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index e5fadda49..a54e81239 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,5 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -8,8 +9,7 @@ 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', + executablePath: chromeExecutablePath(), args: [ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', @@ -307,7 +307,7 @@ try { result.pixelPass = pixels.pass; result.pixelDetails = pixels; result.errors = errors; - await page.screenshot({ path: `${shots}/recovery.png` }); + await captureQaScreenshot(page, { 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 = diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 1ceb45b22..f325cb9f7 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,4 @@ +import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; // Not registered or active: camera discovery and live polls never own this feed. @@ -219,7 +220,7 @@ export async function runFleetBudgets({ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-fleet-${count}.jpg`, type: 'jpeg', quality: 85, @@ -392,7 +393,7 @@ export async function runBostonMatrix({ JSON.stringify({ pixels, contrast }), { unexercised: pixels.length < 6 }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, type: 'jpeg', quality: 90, @@ -407,7 +408,7 @@ export async function runBostonMatrix({ target.key, ); await wait(500); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', quality: 90, @@ -431,7 +432,7 @@ export async function runBostonMatrix({ ); } await wait(300); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', quality: 90, @@ -579,7 +580,7 @@ export async function runBostonLive({ JSON.stringify(selection), { unexercised: !target }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-boston-live-${view.name}.jpg`, type: 'jpeg', quality: 90, @@ -1010,7 +1011,7 @@ export async function runTrailVisibility({ stable: pixels.stable[i], eligible: pixels.eligible[i], })); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, type: 'jpeg', quality: 95, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index aac1d8f60..49a25fd18 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,9 +36,11 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; +import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js'; import { reduceAnchor, @@ -46,8 +48,6 @@ import { reduceScenario, reduceSensorContrast, reduceScriptedMotion, - reduceMotion, - reduceFleet, } from '../src/layers/transit/qaMetrics.js'; const BASE = process.env.QA_BASE_URL || 'http://localhost:4173'; @@ -262,10 +262,10 @@ await mkdir(SHOTS, { recursive: true }); const browser = await puppeteer.launch({ headless: false, protocolTimeout: 150000, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', + '--disable-backgrounding-occluded-windows', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1920,1080', @@ -273,6 +273,10 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +// This gate measures visible-page motion. OS minimization must not silently +// switch it into the app's intentional hidden-tab polling pause. +const focus = await page.createCDPSession(); +await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); boundPageEvaluations(page); await page.setViewport({ width: 1920, height: 1080 }); const pageErrors = []; @@ -288,11 +292,13 @@ page.on('request', (request) => { }); const shot = (name, caption) => - page - .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) - .then(() => { - console.log(` shot ${name} — ${caption}`); - }); + captureQaScreenshot(page, { + path: `${SHOTS}/${name}`, + type: 'jpeg', + quality: 82, + }).then(() => { + console.log(` shot ${name} — ${caption}`); + }); const view = (city) => `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; @@ -391,22 +397,42 @@ try { orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, }); }, city); - await page.waitForFunction( - () => - (window.__godsEyeView.dataManager.layers - .get('transit') - .module.getStats().count || 0) > 0, - { timeout: 90_000 }, - ); + const arrived = await page + .waitForFunction( + (feedId) => + [ + ...window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitStateForTest() + ._vehicles.keys(), + ].some((key) => key.startsWith(`${feedId}:`)), + { timeout: 90_000 }, + city.feedId, + ) + .then( + () => true, + () => false, + ); + if (!arrived) { + check( + `${city.label}: live feed arrived`, + false, + `No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`, + { unexercised: true }, + ); + continue; + } await wait(9_000); - const fleet = await page.evaluate(() => { + const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; return { - count: layer._transitStateForTest()._vehicles.size, + count: [...layer._transitStateForTest()._vehicles.keys()].filter( + (key) => key.startsWith(`${feedId}:`), + ).length, stats: layer.getStats(), }; - }); + }, city.feedId); check( `${city.label}: vehicles are rendered`, fleet.count >= 20, @@ -428,6 +454,7 @@ try { .module._transitStateForTest(); let best = null; for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); if (!best || d < best.d) best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; @@ -1609,6 +1636,7 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const frames = []; + const observedPolls = new Set(); const publicationsBefore = state._cardPublications || 0; let headSamples = 0, headErrorPx = 0, @@ -1625,6 +1653,13 @@ try { const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + if ( + entry.marker.show !== false && + rect && + Number.isFinite(entry.pollSeq) + ) { + observedPolls.add(entry.pollSeq); + } frames.push({ t: performance.now() - t0, mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth, @@ -1670,7 +1705,15 @@ try { headSamples++; } } - if (performance.now() - t0 >= durationMs) finish(); + const elapsed = performance.now() - t0; + // Keep at least 20 seconds of motion and require an observed poll + // transition. A fixed 20s wall-clock window can miss a 15s poll + // when an earlier refresh is still settling. + if ( + (elapsed >= 20_000 && observedPolls.size >= 2) || + elapsed >= durationMs + ) + finish(); }; let remove; const finish = () => { @@ -1678,6 +1721,7 @@ try { remove?.(); resolve({ selected: true, + elapsedMs: performance.now() - t0, frames, textPublishes: (state._cardPublications || 0) - publicationsBefore, @@ -1692,7 +1736,7 @@ try { }); }, target.key, - 20_000, + 3 * TRANSIT_POLL_MS, ); } check( @@ -1713,8 +1757,9 @@ try { ); check( 'card text publication stays throttled while the anchor runs at frame rate', - anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3, - `${anchor.textPublishes} text publications in 20 s`, + anchor.textPublishes > 0 && + anchor.textPublishes <= anchor.elapsedMs / 250 + 3, + `${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`, ); } else { check( diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index d393648df..ac89ec5d0 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -43,11 +43,7 @@ try { const fpsReadout = document.querySelector('.frame-rate-readout'); const fpsWasVisible = fpsReadout && !fpsReadout.hidden; const watchObserver = (name) => { - const owner = - name === '_draggableResizeObserver' - ? ui._panelPosition - : ui._panelLayout; - const observer = owner[name]; + const observer = ui._panelLayout[name]; if (!observer) return false; const disconnect = observer.disconnect.bind(observer); counts[name] = 0; @@ -59,7 +55,6 @@ try { }; const observed = [ '_commandDockTrayObserver', - '_draggableResizeObserver', '_leftStackResizeObserver', '_rightStackResizeObserver', '_leftStackMutationObserver', @@ -139,7 +134,6 @@ try { const focusBefore = document.activeElement; const stoppedBeforeRestoration = ui._panelLayout.destroyed && - ui._panelPosition.destroyed && ui._feedback.destroyed && ui._recording.destroyed && cockpit.destroyed && @@ -194,11 +188,7 @@ try { counts[field] === 1 && ui._contextControls[field] === null, ), observersReleased: observed.every( - (name) => - counts[name] === 1 && - (name === '_draggableResizeObserver' - ? ui._panelPosition - : ui._panelLayout)[name] === null, + (name) => counts[name] === 1 && ui._panelLayout[name] === null, ), subscriptionReleased: counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null, diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index b5ad8ac87..278c1c71f 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -295,7 +295,7 @@ async function main() { ); // Wait for the photoreal tileset and at least one vessel refresh. - const settled = await page + await page .waitForFunction(() => { const gev = window.__godsEyeView; const t = gev.tileset; diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 3117e1414..5e3991806 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -183,6 +183,38 @@ try { return manager.bloomEnabled !== before; }), ); + const sliders = await page.evaluate(() => { + const ordinary = [ + ...document.querySelectorAll('input[type="range"]'), + ].filter( + (input) => !input.matches('#radio-tuner-slider, .bhote-event-range'), + ); + return { + count: ordinary.length, + shared: ordinary.every((input) => + input.classList.contains('gev-quantitative-slider'), + ), + surface: ordinary.every((input) => { + const style = getComputedStyle(input); + return ( + style.height === '18px' && + style.appearance === 'none' && + style.padding === '0px' && + style.borderWidth === '0px' && + (!input.disabled || + (style.opacity === '0.42' && style.cursor === 'not-allowed')) + ); + }), + tunerSeparate: !document + .getElementById('radio-tuner-slider') + .classList.contains('gev-quantitative-slider'), + }; + }); + check( + 'ordinary sliders share one native surface', + sliders.count > 10 && sliders.shared && sliders.surface, + ); + check('radio tuning keeps its specialized control', sliders.tunerSeparate); fs.mkdirSync('qa-shots/visual-input', { recursive: true }); await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' }); await page.setViewport({ width: 620, height: 900 }); diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index f1b2b0ced..1c5c2eb83 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -1745,8 +1745,9 @@ async function runBehaviorLayer() { '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. + // Require the bundled boundary source and the existing whole-query limit. + // This elapsed time includes record acquisition and filtering; the region + // resolver has its own separate fallback budget. const gulfStarted = Date.now(); r = await run('analyst_query', { layers: ['flights'], @@ -1757,9 +1758,10 @@ async function runBehaviorLayer() { report( r?.ok === true && String(r?.coverage?.scope || '').includes('Gulf of Mexico') && + r?.coverage?.scopeSource === 'natural-earth' && gulfMs < 3000, 'behavior: analyst resolves the Gulf of Mexico from the bundled pack', - `scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`, + `scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`, ); // (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band) diff --git a/scripts/qa-weather-swap.mjs b/scripts/qa-weather-swap.mjs index e5b26d723..da7f77bb2 100644 --- a/scripts/qa-weather-swap.mjs +++ b/scripts/qa-weather-swap.mjs @@ -8,13 +8,7 @@ * 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 { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; diff --git a/scripts/qa-weather-teardown.mjs b/scripts/qa-weather-teardown.mjs index b64d1d59c..bf7b00269 100644 --- a/scripts/qa-weather-teardown.mjs +++ b/scripts/qa-weather-teardown.mjs @@ -61,6 +61,10 @@ const browser = await puppeteer.launch({ const boot = async () => { const page = await browser.newPage(); + // This probe measures active rendering; an OS-minimized window must not + // silently exercise the application's intentional hidden-page pause. + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 }); const errors = []; page.on('pageerror', (e) => @@ -141,7 +145,7 @@ const snapshot = (page) => ), }, weather, - windCells: wind.gpu?.pathCount ?? wind.paths ?? null, + windCells: wind.gpu?.pathCount ?? wind.paths ?? 0, enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id), }; }); @@ -220,7 +224,7 @@ try { 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 + const weatherReady = await a.page .waitForFunction( () => ['weather-radar', 'weather-satellite', 'weather-lightning'].every( @@ -233,7 +237,13 @@ try { ), { timeout: 120_000 }, ) - .catch(() => console.log(' !! weather not ready')); + .then( + () => true, + () => false, + ); + results.weatherReady = weatherReady; + if (!weatherReady) + console.log(' !! weather not ready — feature coverage incomplete'); await sleep(2000); // exercise: history playback, wind reading, storm selection, product switch await a.page.evaluate(() => { @@ -331,11 +341,25 @@ try { 'errors (control run):', results.errorsB.length ? results.errorsB : 'none', ); + // Resource ownership is an acceptance check; provider availability remains + // separately reported diagnostic evidence in both runs. + if ( + !weatherReady || + Object.keys(leak).length || + [...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:')) + ) { + process.exitCode = 1; + } +} catch (error) { + results.failure = { name: error.name, message: error.message }; + throw error; +} finally { + // Preserve completed resource evidence even if a later provider or browser + // phase aborts. A partial run still exits nonzero. writeFileSync( path.join(outDir, 'results.json'), JSON.stringify(results, null, 2), ); console.log(`wrote ${path.join(outDir, 'results.json')}`); -} finally { await browser.close(); } diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 9bd7e70e0..3712df28e 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -1,7 +1,7 @@ import { readdirSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; export const ALLOCATION_TEST_FILES = Object.freeze([ 'src/data/focusAllocations.test.mjs', @@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { return version; } +/** + * Directories this runner searches for `*.test.mjs`, in stable order. + * + * `server/` belongs here because provider modules own tests of their own: a + * suite placed beside the module it covers must run, and CI's unit-test job runs + * `npm test`, so a directory missing from this list is a suite that never runs + * there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs` + * outside dependencies, build output and dot-directories, so adding a tree + * without adding it here fails. + */ +export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']); + /** Discover repository unit tests in stable path order. */ export function discoverUnitTestFiles(root = process.cwd()) { - const sourceRoot = path.join(root, 'src'); const files = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) { } } }; - visit(sourceRoot); + for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot)); return files.sort(); } diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 49c6722c2..7075be2c1 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -76,10 +76,15 @@ export function hasRequiredDependencies(rootDir = ROOT) { } } -/** Return the npm command and spawn mode required by the target platform. */ -export function npmProcessSpec(platform = process.platform) { - const windows = platform === 'win32'; - return { command: windows ? 'npm.cmd' : 'npm', shell: windows }; +/** + * Return the npm command, arguments and spawn mode for the target platform. + * Windows must run npm.cmd through a shell, and Node 24 deprecates passing an + * argument array with `shell: true` (DEP0190), so there the arguments, which + * are fixed npm subcommands, join the command string. + */ +export function npmProcessSpec(args, platform = process.platform) { + if (platform !== 'win32') return { command: 'npm', args, shell: false }; + return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true }; } /** Read one key from Vite's dotenv file ladder without depending on Vite. */ @@ -119,6 +124,19 @@ export function resolveOpenSkyAuthMode({ return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; } +/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */ +export function resolveOpenSkyCredentialsFile({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE'); + const value = authoritativeEnvironment && environmentDefines + ? String(environment.OPENSKY_CREDENTIALS_FILE || '') + : String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir); + return isConfiguredValue(value.trim()); +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -148,10 +166,11 @@ export function resolveCredential(spec, { export function buildCapabilitySummary( credentials, - { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {}, ) { const configured = (name) => credentials[name]?.configured === true; - const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true; const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) ? requestedOpenSkyMode @@ -159,11 +178,13 @@ export function buildCapabilitySummary( const flights = openSkyMode === 'anon' ? 'OpenSky keyless anonymous access (rate-limited)' : openSkyMode === 'basic' - ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + ? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)' : openSkyMode === 'auto' ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' - : hasOAuthCredentials + : hasClientPair ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)' : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', @@ -190,8 +211,8 @@ export function buildCapabilitySummary( export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) { const node = classifyNodeVersion(); - const npm = npmProcessSpec(); - const npmResult = spawnSync(npm.command, ['--version'], { + const npm = npmProcessSpec(['--version']); + const npmResult = spawnSync(npm.command, npm.args, { encoding: 'utf8', shell: npm.shell, }); @@ -210,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment credentials, capabilities: buildCapabilitySummary(credentials, { openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }), }), }; } diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 84aa2494e..044fdf74f 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -94,6 +94,7 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; +import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; @@ -443,7 +444,10 @@ async function main() { // for nearby names. Answer both with empty results: the entity-context // assertions are about the SELECTED contact, and a live lookup would // make the run non-hermetic (and bill the owner's Google quota). - if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) { + if ( + isAppRequest && + (url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode') + ) { return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] })); } if (isAppRequest && url.pathname === '/api/google/nearby-places') { @@ -2692,16 +2696,17 @@ async function main() { // reappear wearing its stale/creation-north nose for up to 1 s). // Staged deterministically: park the camera nadir over the synthetic // cluster, let rotations settle, TAMPER a billboard's rotation, then - // trigger each path with the camera idle. The idle-drift catch-up - // (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a - // ~300 ms SwiftShader frame window, so a regression shows as an - // intermittent (not guaranteed) failure — never a false failure. + // trigger each path with the camera idle. The fleet runs every 80 ms, + // independently of rendered FPS. Observe the first completed settle and + // reveal frames within 500 ms of forcing moveEnd, before the 1 s idle + // rotation refresh can repair an incorrect first reveal. // ============================================================ console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass'); const arrival = await evalPage(async () => { const v = window.__godsEyeView.viewer; const dm = window.__godsEyeView.dataManager; const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`); + const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`); // 3D models OFF for this phase: a model-handed-off billboard is hidden // and skips rotation updates entirely — the probes need live billboards // (this is also the app's default state the field report came from). @@ -2763,39 +2768,29 @@ async function main() { const bb = findBB(id); if (!bb || !bb.show) return { error: `${id} missing/hidden` }; const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below - // (a) settle pass: tamper, then raise moveEnd with the camera IDLE — - // the pose signature is unchanged, so only the moveEnd hook can fix - // this before the 1 s catch-up. - bb.rotation = r0 + 1.5; - v.camera.moveEnd.raiseEvent(); - await nextFrames(2); - const afterMoveEnd = bb.rotation; - // A real-GPU fleet tick can land near the edge of the frame budget and - // advance the contact before the probe reads it. Compare with the - // production projection at the CURRENT position, not the now-stale r0. - const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null); - // (b) reveal pass: tamper + hide — the next fleet tick must flip it - // visible AND correct the nose in that same tick (camera still idle, - // no moveEnd raised). Diagnostic fields (round 5): record WHICH frame - // the flip landed on (flipFrame; -1 = never within the extended - // window) and whether a 3D model owns this id — distinguishes "tick - // slow under load" from "occluder says hidden" from "model handoff". - bb.rotation = r0 + 1.5; - bb.show = false; - let flipFrame = -1; - for (let f = 1; f <= 6; f++) { - await nextFrames(1); - if (bb.show && flipFrame === -1) flipFrame = f; - if (f >= 2 && flipFrame !== -1) break; - } - const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null); + const observed = await observeArrivalRotation({ + scene: v.scene, + startMoveEnd() { + // Only moveEnd can repair the tampered angle at the idle pose. + bb.rotation = r0 + 1.5; + v.camera.moveEnd.raiseEvent(); + }, + startReveal() { + // Hide immediately after the confirmed settle frame. The next + // eligible fleet tick must reveal AND re-aim in that same tick. + bb.rotation = r0 + 1.5; + bb.show = false; + }, + read() { + return { + shown: bb.show, + rotationError: angDiff(bb.rotation, + screenProjectedRotation(v.scene, bb.position, course, null)), + }; + }, + }); const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id)); - return { - r0, - dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd), - dReveal: angDiff(bb.rotation, expectedReveal), - shown: bb.show, flipFrame, hasModel, - }; + return { r0, ...observed, hasModel }; }; const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0; const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0; @@ -2804,22 +2799,13 @@ async function main() { military: await probe('bbb101', militaryCourse), }; }); - const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error - : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`); + const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms` + : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`); const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off - // Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe - // shows the military flip consistently lands on frame 2 — the exact edge - // of the old budget — so any load hiccup flaked it (flights lands frame - // 1). The regression this protects against is a reveal that waits for - // the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still - // catches that with a wide margin, with zero-headroom flakes gone. - const arrOk = (a) => !!a && !a.error && a.shown - && (a.flipFrame === -1 ? false : a.flipFrame <= 4) - && a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10; - record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.flights), fmtArr(arrival.flights)); - record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.military), fmtArr(arrival.military)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.military), fmtArr(arrival.military)); // ============================================================ // DISPLAY FLOOR (2026-08-19): a grounded contact must never render below diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 54ad48358..0ec8cdacd 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,56 +1,175 @@ -import { readFileSync } from 'node:fs'; -import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js'; -import { tilesForBounds } from '../src/data/tomtomTiles.js'; +import { PbfWriter } from 'pbf'; -const tile = readFileSync( - new URL( - '../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf', - import.meta.url, - ), -); +const corsHeaders = { 'access-control-allow-origin': '*' }; const json = (body) => ({ status: 200, + headers: corsHeaders, contentType: 'application/json', body: JSON.stringify(body), }); -/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */ +// Cesium heightmap-1.0: 65x65 little-endian uint16 heights, scale .2, +// offset -1000, followed by child availability and a uniform land mask. +const flatTerrain = Buffer.alloc(65 * 65 * 2 + 2); +for (let i = 0; i < 65 * 65; i++) flatTerrain.writeUInt16LE(5000, i * 2); + +/** Synthetic, geographically aligned motor roads at both source zooms. */ +export function trafficFixtureTile(zoom, flow = false) { + const writer = new PbfWriter(); + writer.writeMessage( + 3, + (_, layer) => { + layer.writeVarintField(15, 2); + layer.writeStringField(1, flow ? 'Traffic flow' : 'transportation'); + layer.writeVarintField(5, 4096); + layer.writeStringField(3, flow ? 'traffic_level' : 'class'); + layer.writeMessage( + 4, + (_, value) => { + if (flow) value.writeDoubleField(3, 0.6); + else value.writeStringField(1, 'primary'); + }, + null, + ); + if (flow) { + layer.writeStringField(3, 'road_type'); + layer.writeMessage( + 4, + (_, value) => value.writeStringField(1, 'Major road'), + null, + ); + } + // z12 has four times the span of z14. Keep the same world spacing, + // and cover only alternate roads with flow to exercise hide/sim controls. + const step = 256 * 2 ** (zoom - 14); + for (let y = step; y < 4096; y += step) { + if (flow && (y / step) % 2) continue; + layer.writeMessage( + 2, + (_, feature) => { + feature.writeVarintField(3, 2); + feature.writePackedVarint(2, flow ? [0, 0, 1, 1] : [0, 0]); + feature.writePackedVarint(4, [9, 0, y * 2, 10, 8192, 0]); + }, + null, + ); + } + }, + null, + ); + return Buffer.from(writer.finish()); +} + +/** Intercept the current vector sources; never fall through to live roads. */ export function trafficFixtureResponse(request) { const url = new URL(request.url()); + if (url.hostname === 'terrain.reearth.land') { + if (url.pathname.endsWith('/layer.json')) + return json({ + tilejson: '2.1.0', + format: 'heightmap-1.0', + version: 'qa-flat', + scheme: 'tms', + projection: 'EPSG:4326', + minzoom: 0, + maxzoom: 0, + tiles: ['{z}/{x}/{y}.terrain?v={version}'], + available: [[{ startX: 0, startY: 0, endX: 1, endY: 0 }]], + attribution: + 'Synthetic flat terrain for Traffic QA; not live elevation data', + }); + if (/\/0\/[01]\/0\.terrain$/.test(url.pathname)) + return { + status: 200, + contentType: 'application/octet-stream', + headers: corsHeaders, + body: flatTerrain, + }; + throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); + } if (url.pathname === '/api/tomtom/status') return json({ hasKey: true, dailyCount: 0 }); - if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname)) - return { status: 200, contentType: 'application/x-protobuf', body: tile }; - if (url.pathname !== '/api/overpass') return null; - const query = new URLSearchParams(request.postData()).get('data') || ''; - if (!query.includes('highway')) return null; - const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/); - if (!match) throw new Error('Missing road fixture viewport'); - const [south, west, north, east] = match.slice(1).map(Number); - const inside = ([lon, lat]) => - lat >= south && lat <= north && lon >= west && lon <= east; - const segments = tilesForBounds({ south, west, north, east }, 12).flatMap( - ({ z, x, y }) => decodeFlowTile(tile, z, x, y), + const flow = url.pathname.match( + /^\/api\/tomtom\/flow\/(\d+)\/\d+\/\d+\.pbf$/, ); - const elements = segments - .filter((segment) => segment.coords.some(inside)) - .slice(0, 240) - .map((segment, index) => ({ - type: 'way', - id: index + 1, - tags: { highway: 'primary', oneway: 'yes' }, - geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })), - })); - const latitude = (south + north) / 2, - longitude = (west + east) / 2; - elements.push({ - type: 'way', - id: 99999, - tags: { highway: 'residential' }, - geometry: [ - { lat: latitude, lon: longitude }, - { lat: latitude + 0.003, lon: longitude + 0.003 }, - ], + if (flow) + return { + status: 200, + contentType: 'application/x-protobuf', + headers: corsHeaders, + body: trafficFixtureTile(Number(flow[1]), true), + }; + if (url.hostname !== 'tiles.openfreemap.org') return null; + if (url.pathname === '/planet') + return json({ + tilejson: '3.0.0', + minzoom: 0, + maxzoom: 14, + tiles: ['https://tiles.openfreemap.org/qa-traffic/{z}/{x}/{y}.pbf'], + }); + const road = url.pathname.match(/^\/qa-traffic\/(\d+)\/\d+\/\d+\.pbf$/); + if (!road) + throw new Error(`Unrecognized road fixture request: ${url.pathname}`); + return { + status: 200, + contentType: 'application/x-protobuf', + headers: corsHeaders, + body: trafficFixtureTile(Number(road[1])), + }; +} + +/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */ +export async function installTrafficRoutes( + page, + { + origin, + fixtures = false, + keyless = false, + onFlow = () => {}, + onError = () => {}, + }, +) { + const session = await page.createCDPSession(); + const patterns = [ + `${origin}/api/tomtom/status*`, + `${origin}/api/tomtom/flow/*`, + ...(fixtures + ? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*'] + : []), + ].map((urlPattern) => ({ urlPattern, requestStage: 'Request' })); + session.on('Fetch.requestPaused', async ({ requestId, request }) => { + try { + const url = new URL(request.url); + let response; + if (keyless && url.pathname === '/api/tomtom/status') { + response = json({ hasKey: false }); + } else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) { + onFlow(request.url); + response = { ...json({ error: 'no_key' }), status: 503 }; + } else if (fixtures) { + response = trafficFixtureResponse({ url: () => request.url }); + } + if (!response) { + await session.send('Fetch.continueRequest', { requestId }); + return; + } + await session.send('Fetch.fulfillRequest', { + requestId, + responseCode: response.status, + responseHeaders: Object.entries({ + 'content-type': response.contentType, + ...response.headers, + }).map(([name, value]) => ({ name, value })), + body: Buffer.from(response.body).toString('base64'), + }); + } catch (error) { + if (page.isClosed()) return; + onError(error); + await session + .send('Fetch.failRequest', { requestId, errorReason: 'Failed' }) + .catch(() => {}); + } }); - return json({ elements }); + await session.send('Fetch.enable', { patterns }); + return session; } diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 2f3f73a4c..9d1bcc8ba 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([ // — camera verbs (tools #23/#24) — { phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] }, { phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] }, + { phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] }, + { phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] }, + { phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] }, { phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] }, { phrase: 'Fly the route we just drew', expect: 'fly_route' }, diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index f67afed1d..b70da4f32 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -1,3 +1,8 @@ +import { + coalesceProxyRequest, + readResponseTextCapped, +} from '../common/http.js'; + /** * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. * @@ -17,6 +22,7 @@ export function adsbLolProxy() { /** @type {string|null} Cached upstream JSON body. */ let _cache = null; + const inFlight = new Map(); /** @type {number} Epoch-ms when the cache was populated. */ let _cacheAt = 0; /** @type {number} Epoch-ms until which upstream is not contacted. */ @@ -25,6 +31,10 @@ export function adsbLolProxy() { let _cooldownStatus = 0; /** Response cache TTL (ms). */ const CACHE_MS = 12000; + /** Upstream deadline (ms); the fetch previously had none and could hang a poll. */ + const UPSTREAM_TIMEOUT_MS = 12000; + /** /v2/mil measures ~67 KB; a relayed error page is bounded by the same cap. */ + const ADSBLOL_MIL_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */ const RATE_LIMIT_COOLDOWN_MS = 30000; /** Cooldown after a 5xx (ms). */ @@ -98,50 +108,73 @@ export function adsbLolProxy() { ); return; } - const upstream = await fetch('https://api.adsb.lol/v2/mil', { - headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, - }); - if (upstream.ok) { - const body = await upstream.text(); - _cache = body; - _cacheAt = Date.now(); - _cooldownUntil = 0; - _cooldownStatus = 0; - serve(res, 200, body, 'MISS'); - return; - } - if (upstream.status === 429 || upstream.status >= 500) { - const failedAt = Date.now(); - _cooldownUntil = failedAt + cooldownFor(upstream, failedAt); - _cooldownStatus = upstream.status; - console.warn( - `[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`, - ); - if (_cache) { - // Do not wait for a failing response body before serving usable data. - upstream.body?.cancel().catch(() => {}); - serve(res, 200, _cache, 'STALE', { - 'X-ADS-B-Upstream-Status': String(upstream.status), - 'X-ADS-B-Retry-After-Seconds': String( - Math.ceil((_cooldownUntil - Date.now()) / 1000), - ), - }); - return; + const refresh = coalesceProxyRequest(inFlight, 'global', async () => { + const result = (status, body, cacheStatus, extra = {}) => ({ + status, + body, + cacheStatus, + extra, + }); + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + const upstream = await fetch('https://api.adsb.lol/v2/mil', { + headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, + signal, + }); + if (upstream.ok) { + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); + _cache = body; + _cacheAt = Date.now(); + _cooldownUntil = 0; + _cooldownStatus = 0; + return result(200, body, 'MISS'); } - } - const body = await upstream.text(); - serve( - res, - upstream.status, - body, - 'MISS', - _cooldownUntil > Date.now() - ? { - 'Retry-After': String( + if (upstream.status === 429 || upstream.status >= 500) { + const failedAt = Date.now(); + _cooldownUntil = failedAt + cooldownFor(upstream, failedAt); + _cooldownStatus = upstream.status; + console.warn( + `[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`, + ); + if (_cache) { + // Do not wait for a failing response body before serving usable data. + upstream.body?.cancel().catch(() => {}); + return result(200, _cache, 'STALE', { + 'X-ADS-B-Upstream-Status': String(upstream.status), + 'X-ADS-B-Retry-After-Seconds': String( Math.ceil((_cooldownUntil - Date.now()) / 1000), ), - } - : {}, + }); + } + } + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); + return result( + upstream.status, + body, + 'MISS', + _cooldownUntil > Date.now() + ? { + 'Retry-After': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + } + : {}, + ); + }); + const response = await refresh.promise; + serve( + res, + response.status, + response.body, + response.cacheStatus, + response.extra, ); } catch (e) { console.error('[adsb.lol Proxy]', e.message); diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js index 400050151..4c069fa7f 100644 --- a/server/providers/aircraft/enrichment.js +++ b/server/providers/aircraft/enrichment.js @@ -1,10 +1,15 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { readResponseJsonCapped } from '../common/http.js'; + +/** Route and aircraft lookups are a few hundred bytes; 1 MiB is a hard bound. */ +const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024; /** * adsbdb.com enrichment proxy: callsign → route (airline + origin/destination * airports) and hex → aircraft type/registration. Free community API — cached - * aggressively: ONE upstream request per new key ever (404s negative-cached), - * persisted to disk so restarts don't re-hammer it. Adapted from skylight + * aggressively: successes and 404s are kept per key for 24 hours (TTL_MS) and + * persisted to disk so restarts don't re-hammer it; other failures are not + * cached. Adapted from skylight * (MIT) server/src/enrich/routes.ts. */ export function adsbdbProxy() { @@ -80,12 +85,16 @@ export function adsbdbProxy() { kind === 'route' ? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}` : `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`; - const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + const signal = AbortSignal.timeout(8000); + const res = await fetch(url, { signal }); if (res.ok) { + const payload = await readResponseJsonCapped( + res, + ADSBDB_MAX_RESPONSE_BYTES, + signal, + ); const data = - kind === 'route' - ? parseRoute(await res.json()) - : parseAircraft(await res.json()); + kind === 'route' ? parseRoute(payload) : parseAircraft(payload); store[key] = { at: Date.now(), data }; // data may be null — negative cache dirty = true; return data; diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 187049aea..f7019d752 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,7 +1,10 @@ +import fs from 'node:fs'; +import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; import { coalesceProxyRequest, readResponseJsonCapped, + readResponseTextCapped, } from '../common/http.js'; import { requiredFiniteQueryNumber } from '../common/query.js'; // --------------------------------------------------------------------------- @@ -29,11 +32,34 @@ const OPENSKY_CACHE_MS = 9000; const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000; const OPENSKY_STATES_URL = 'https://opensky-network.org/api/states/all?extended=1'; +/** states/all?extended=1 measures ~0.8 MB anonymously; bounded, not budgeted. */ +const OPENSKY_STATES_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; + +/** + * Credit-governor cooldown for a 429: OpenSky's retry-after bounded to + * 30 s … 30 min, or 2 min when the header is absent. + * @param {Response} upstream Rate-limited response. + * @returns {number} Cooldown (ms). + */ +function openSkyRateLimitCooldownMs(upstream) { + // headers.get() gives null when absent, and Number(null) is 0, not NaN. + const header = upstream.headers.get('x-rate-limit-retry-after-seconds'); + const retryAfterSec = + header == null || header.trim() === '' ? NaN : Number(header); + return Math.min( + Math.max( + Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, + 30_000, + ), + 30 * 60_000, + ); +} /** * Fetch and read the global snapshot, giving each attempt * OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or - * the connection fails. A second failure throws, which the proxy answers + * the connection fails. An oversized body is not retried: it would fail again + * and spend more credits. A second failure throws, which the proxy answers * from its stale cache or the regional fallback. * @param {Record} headers Request headers. * @returns {Promise<{upstream: Response, body: string}>} @@ -41,13 +67,36 @@ const OPENSKY_STATES_URL = async function fetchOpenSkyStates(headers) { for (let attempt = 1; ; attempt++) { try { - const upstream = await fetch(OPENSKY_STATES_URL, { - headers, - signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS), - }); - return { upstream, body: await upstream.text() }; + const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS); + const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal }); + if (upstream.status === 429 || upstream.status >= 500) { + // Start the cooldown from the headers and drop the rejected body + // unread, so a stalled or oversized 429 body cannot skip it. + _openskyCooldownUntil = + Date.now() + + (upstream.status === 429 + ? openSkyRateLimitCooldownMs(upstream) + : 15000); + _openskyCooldownStatus = upstream.status; + void upstream.body?.cancel().catch(() => {}); + return { + upstream, + body: JSON.stringify({ + error: + upstream.status === 429 + ? 'OpenSky rate limited.' + : 'OpenSky upstream unavailable.', + }), + }; + } + const body = await readResponseTextCapped( + upstream, + OPENSKY_STATES_MAX_RESPONSE_BYTES, + signal, + ); + return { upstream, body }; } catch (error) { - if (attempt >= 2) throw error; + if (attempt >= 2 || error?.code === 'RESPONSE_TOO_LARGE') throw error; console.warn( '[OpenSky] global snapshot attempt failed, retrying:', error?.message || error, @@ -71,6 +120,8 @@ async function fetchOpenSkyStates(headers) { let _openskyTtlMs = OPENSKY_CACHE_MS; /** @type {number} Epoch-ms before which no upstream fetch is attempted. */ let _openskyCooldownUntil = 0; +let _openskyCooldownStatus = 429; +const _openskySnapshotInFlight = new Map(); /** * Picks the cache TTL from the remaining daily credit budget. * Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the @@ -88,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) { } /** @type {boolean} Guards duplicate auth-failure warnings in logs. */ let _openskyAuthWarned = false; +/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */ +let _openskyCredentialsFileWarned = false; +/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */ +let _openskyBasicRetiredWarned = false; /** @type {boolean} Guards duplicate invalid-auth-mode warnings. */ let _openskyAuthModeWarned = false; /** Default auth mode when OPENSKY_AUTH_MODE env is unset. */ @@ -107,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // current instead of coasting a stale worldwide frame indefinitely. const OPENSKY_SOURCE_STALE_MS = 120_000; +/** + * The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and + * OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account + * page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home + * directory). The file is read on each token refresh, so a replaced file is + * picked up without a restart. + * + * @param {Record} [env] + * @param {(path: string) => string} [readFile] + * @returns {{ clientId: string, clientSecret: string } | null} + */ +export function openSkyClientCredentials( + env = process.env, + readFile = (path) => fs.readFileSync(path, 'utf8'), +) { + const clientId = String(env.OPENSKY_CLIENT_ID || '').trim(); + const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim(); + if (clientId && clientSecret) return { clientId, clientSecret }; + const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim(); + if (!file) return null; + const path = file.replace(/^~(?=$|[\\/])/, os.homedir()); + let raw; + try { + raw = JSON.parse(readFile(path)); + } catch { + if (!_openskyCredentialsFileWarned) { + console.warn( + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ); + _openskyCredentialsFileWarned = true; + } + return null; + } + const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim(); + const fileSecret = String( + raw?.clientSecret ?? raw?.client_secret ?? '', + ).trim(); + return fileId && fileSecret + ? { clientId: fileId, clientSecret: fileSecret } + : null; +} + /** * Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed. * @@ -125,9 +222,9 @@ export async function getOpenSkyToken() { // return the same promise instead of issuing a duplicate token request if (_openskyTokenPromise) return _openskyTokenPromise; - const clientId = process.env.OPENSKY_CLIENT_ID; - const clientSecret = process.env.OPENSKY_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; + const credentials = openSkyClientCredentials(); + if (!credentials) return null; + const { clientId, clientSecret } = credentials; // Wrap the async token fetch in a shared promise stored in _openskyTokenPromise _openskyTokenPromise = (async () => { @@ -368,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { * * Supports four auth modes controlled by OPENSKY_AUTH_MODE env: * - 'oauth' (default) — client_credentials bearer token - * - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD - * - 'auto' — try OAuth first, fall back to Basic, then anon + * - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and + * password) on 2026-03-18, so this requests anonymously and + * warns once + * - 'auto' — try OAuth first, then anon * - 'anon' — no credentials * * Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On @@ -422,7 +521,9 @@ export function openSkyProxy() { requestedMode: cachedMeta.requestedMode || requestedMode, usedMode: cachedMeta.usedMode || 'unknown', reason: isStale - ? 'rate_limited_serving_stale' + ? _openskyCooldownStatus === 429 + ? 'rate_limited_serving_stale' + : `upstream_http_${_openskyCooldownStatus}_serving_stale` : cachedMeta.reason || 'cached', staleSeconds: isStale ? (now - _openskyCacheTime) / 1000 @@ -449,80 +550,114 @@ export function openSkyProxy() { ) return; res.writeHead( - 429, + _openskyCooldownStatus, buildOpenSkyHeaders({ cacheStatus: 'COOLDOWN', requestedMode, usedMode: 'none', - reason: 'rate_limited', + reason: + _openskyCooldownStatus === 429 + ? 'rate_limited' + : `upstream_http_${_openskyCooldownStatus}`, retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, }), ); res.end( JSON.stringify({ - error: 'OpenSky rate limited; proxy cooling down.', + error: 'OpenSky upstream unavailable; proxy cooling down.', }), ); return; } - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); - const headers = { Accept: 'application/json' }; - let usedMode = 'anon'; - let reason = 'forced_anonymous'; + const refresh = coalesceProxyRequest( + _openskySnapshotInFlight, + 'global', + async () => { + const headers = { Accept: 'application/json' }; + let usedMode = 'anon'; + let reason = 'forced_anonymous'; - if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; - } - } else if (requestedMode === 'oauth') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else { - reason = 'oauth_invalid_or_missing'; - } - } else if (requestedMode === 'auto') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; - } else { - reason = 'missing_oauth_and_basic_creds'; - } - } + if (requestedMode === 'basic') { + // OpenSky no longer accepts a Basic header, so sending + // OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask + // anonymously and say once what to configure instead. + if (!_openskyBasicRetiredWarned) { + console.warn( + '[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', + ); + _openskyBasicRetiredWarned = true; + } + reason = 'basic_retired'; + } else if (requestedMode === 'oauth') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else { + reason = 'oauth_invalid_or_missing'; + } + } else if (requestedMode === 'auto') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else { + reason = 'missing_oauth_creds'; + } + } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + const { upstream, body } = await fetchOpenSkyStates(headers); - const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; + const sourceEpochMs = upstream.ok + ? openSkySourceEpochMs(body) + : null; + // Refine the reason string to reflect the actual outcome + if (upstream.ok && reason === 'forced_anonymous') { + reason = 'anonymous_ok'; + } else if ( + upstream.ok && + usedMode === 'oauth' && + reason === 'oauth_token' + ) { + reason = 'oauth_ok'; + } + + // Only cache successful responses — error responses (401/403/429/5xx) + // should not be served from cache on subsequent requests + if (upstream.ok) { + _openskyCacheBody = body; + _openskyCacheStatus = upstream.status; + _openskyCacheTime = now; + _openskyCacheSourceEpochMs = sourceEpochMs; + _openskyCacheMeta = { + requestedMode, + usedMode, + reason, + }; + // Credit governor: adapt the cache TTL to the remaining daily + // budget so a continuously-open app stretches its polls instead of + // exhausting the quota mid-day. Success also clears any cooldown. + const remaining = Number( + upstream.headers.get('x-rate-limit-remaining'), + ); + _openskyTtlMs = openskyAdaptiveTtlMs(remaining); + _openskyCooldownUntil = 0; + } + + return { + upstream, + body, + usedMode, + reason, + sourceEpochMs, + }; + }, + ); + let { upstream, body, usedMode, reason, sourceEpochMs } = + await refresh.promise; if ( upstream.ok && openSkySourceIsStale(sourceEpochMs, now) && @@ -533,30 +668,16 @@ export function openSkyProxy() { 'opensky_snapshot_stale_regional_fallback', )) ) { - // Keep the last global snapshot available as a fail-soft cache, - // but do not label or render it as a fresh live result. - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { requestedMode, usedMode, reason }; return; } - if (upstream.status === 429) { - reason = 'rate_limited'; - // Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min; - // 2 min when the header is absent) — no upstream attempts until then. - const retryAfterSec = Number( - upstream.headers.get('x-rate-limit-retry-after-seconds'), - ); - const cooldownMs = Math.min( - Math.max( - Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, - 30_000, - ), - 30 * 60_000, - ); - _openskyCooldownUntil = now + cooldownMs; + if (upstream.status === 429 || upstream.status >= 500) { + reason = + upstream.status === 429 + ? 'rate_limited' + : `upstream_http_${upstream.status}`; + // Credit governor: fetchOpenSkyStates already started the cooldown + // from OpenSky's retry-after — no upstream attempts until then. + const cooldownMs = Math.max(0, _openskyCooldownUntil - Date.now()); // Serve the last-good body instead of the 429 when we have one — // the layer keeps rendering (STALE-cued) instead of dying. if (_openskyCacheBody && _openskyCacheStatus === 200) { @@ -566,7 +687,7 @@ export function openSkyProxy() { cacheStatus: 'STALE', requestedMode, usedMode, - reason: 'rate_limited_serving_stale', + reason: `${reason}_serving_stale`, staleSeconds: (now - _openskyCacheTime) / 1000, retryAfterSeconds: cooldownMs / 1000, }), @@ -587,35 +708,24 @@ export function openSkyProxy() { } if (upstream.status === 401 || upstream.status === 403) { - if (requestedMode === 'basic' && !hasBasicCreds) { + if (requestedMode === 'oauth' && usedMode !== 'oauth') { body = JSON.stringify({ error: - 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', - }); - reason = 'missing_basic_creds'; - } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); reason = 'oauth_invalid_or_missing'; - } else if (usedMode === 'basic') { - body = JSON.stringify({ - error: 'OpenSky auth invalid. Username/password were rejected.', - }); - reason = 'basic_invalid_credentials'; } else if (usedMode === 'oauth') { body = JSON.stringify({ error: 'OpenSky auth invalid. OAuth client credentials were rejected.', }); reason = 'oauth_invalid_credentials'; - } else if (requestedMode === 'auto' && !hasBasicCreds) { + } else if (requestedMode === 'auto') { body = JSON.stringify({ error: - 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + 'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } else { body = JSON.stringify({ error: 'OpenSky auth required.', @@ -624,45 +734,6 @@ export function openSkyProxy() { } } - // Refine the reason string to reflect the actual outcome - if (upstream.ok && reason === 'forced_anonymous') { - reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; - } else if ( - upstream.ok && - usedMode === 'oauth' && - reason === 'oauth_token' - ) { - reason = 'oauth_ok'; - } - - // Only cache successful responses — error responses (401/403/429/5xx) - // should not be served from cache on subsequent requests - if (upstream.ok) { - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { - requestedMode, - usedMode, - reason, - }; - // Credit governor: adapt the cache TTL to the remaining daily - // budget so a continuously-open app stretches its polls instead of - // exhausting the quota mid-day. Success also clears any cooldown. - const remaining = Number( - upstream.headers.get('x-rate-limit-remaining'), - ); - _openskyTtlMs = openskyAdaptiveTtlMs(remaining); - _openskyCooldownUntil = 0; - } - res.writeHead( upstream.status, buildOpenSkyHeaders({ diff --git a/server/providers/aircraft/openskyCredentials.test.mjs b/server/providers/aircraft/openskyCredentials.test.mjs new file mode 100644 index 000000000..539579517 --- /dev/null +++ b/server/providers/aircraft/openskyCredentials.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { openSkyClientCredentials } from './opensky.js'; + +const file = (contents) => (filePath) => { + file.read = filePath; + if (contents instanceof Error) throw contents; + return contents; +}; + +test('OpenSky client env vars come first', () => { + const read = () => assert.fail('the file is not read when env vars are set'); + assert.deepEqual( + openSkyClientCredentials( + { + OPENSKY_CLIENT_ID: ' id ', + OPENSKY_CLIENT_SECRET: 'secret', + OPENSKY_CREDENTIALS_FILE: 'credentials.json', + }, + read, + ), + { clientId: 'id', clientSecret: 'secret' }, + ); +}); + +test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => { + const env = { + OPENSKY_CLIENT_ID: '', + OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json', + }; + // The file OpenSky's account page downloads uses camelCase keys. + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"clientId":"cid","clientSecret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); + assert.equal( + path.normalize(file.read), + path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')), + ); + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"client_id":"cid","client_secret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); +}); + +test('a missing, unreadable or incomplete credentials file means no client', () => { + assert.equal(openSkyClientCredentials({}, file('{}')), null); + const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' }; + const missing = Object.assign(new Error('nope'), { code: 'ENOENT' }); + assert.equal(openSkyClientCredentials(env, file(missing)), null); + assert.equal(openSkyClientCredentials(env, file('not json')), null); + assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null); +}); + +test('credential file warnings never expose file contents or read errors', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + for (const contents of [ + '{"clientId":"id","clientSecret":FAKE_SECRET_123}', + new Error('could not read FAKE_SECRET_123'), + ]) { + const { openSkyClientCredentials: resolve } = await import( + `./opensky.js?warning=${warnings.length}` + ); + const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' }; + assert.equal(resolve(env, file(contents)), null); + assert.equal(resolve(env, file(contents)), null); + } + assert.deepEqual(warnings, [ + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ]); +}); diff --git a/server/providers/aircraft/tracks.js b/server/providers/aircraft/tracks.js index 5f800ac59..3b0acd8d2 100644 --- a/server/providers/aircraft/tracks.js +++ b/server/providers/aircraft/tracks.js @@ -7,10 +7,9 @@ import { readCappedResponseText } from '../common/http.js'; * own credit bucket, 4 credits per call on the free tier). OAuth via the * shared coalesced token. 60s per-icao cache; 404/429 forwarded so the * client can fall back to its accumulated trail silently. - * /api/military/track?hex= — adsb.lol tar1090 readsb trace - * (undocumented but live; no browser CORS, hence this proxy). Up to ~24h - * of real history per aircraft. Treat as best-effort; data is ODbL — - * credit "adsb.lol (ODbL)" in the UI. + * /api/military/track?hex= — compatibility refusal for the retired, + * undocumented ADSB.lol trace route. The standalone military source uses + * locally accumulated trails; provider-supplied history remains optional. */ export function trackBackfillProxies() { const TRACK_CACHE_MS = 60000; @@ -107,10 +106,16 @@ export function trackBackfillProxies() { ); return; } - await proxyJson( - res, - `lol:${hex}`, - `https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`, + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Retry-After', '1800'); + res.end( + JSON.stringify({ + error: + 'Military history unavailable; locally accumulated trails remain available', + code: 'history_unavailable', + }), ); } catch (error) { res.statusCode = 502; diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 9cf3b0c47..a977f4e75 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -1,11 +1,9 @@ import { createCctvCatalog } from './cctv/catalog.js'; -import { - normalizeFeedType, - isVideoFeedType, - toFiniteNumber, -} from './cctv/normalize.js'; +import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js'; import { buildSyntheticCctvSvg, + cctvLiveMediaType, + cctvRasterImageType, proxyMediaResponse, fetchCctvImageFromUpstream, fetchTxdotSnapshot, @@ -17,6 +15,8 @@ import { CCTV_MAX_SOURCES_CEILING, } from './cctv/constants.js'; import { sanitizeCctvRangeHeader } from './cctv/range.js'; +import { admitSameSiteKeyless } from './common/same-site.js'; +import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs'; import { createHlsPuller } from './cctv/stream.js'; import { googleServerApiKey } from './places/google-key.js'; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; @@ -28,7 +28,7 @@ export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; * GET /api/cctv/sources — list all registered camera sources * GET /api/cctv/health — per-camera health/status report * GET /api/cctv/stream/:id — stream info (feedType, URLs) for a camera - * GET /api/cctv/media/:id — proxy live video/image media from upstream + * GET /api/cctv/media/:id — proxy live video media from upstream * GET /api/cctv/frame/:id — single frame with fallback chain * * @returns {import('vite').Plugin} @@ -116,8 +116,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' }, signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS), }); - const svType = svResp.headers.get('content-type') || ''; - if (!svResp.ok || !svType.startsWith('image/')) return null; + const svType = cctvRasterImageType(svResp.headers.get('content-type')); + if (!svResp.ok || !svType) { + try { + await svResp.body?.cancel(); + } catch { + /* no-op */ + } + return null; + } return { ok: true, @@ -135,15 +142,19 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { }); server.middlewares.use('/api/cctv', async (req, res) => { try { + const url = new URL(req.url || '/', 'http://localhost'); + // Refuse cross-site requests before any catalog or upstream work. The + // keyless gate keeps the routes reachable behind a reverse proxy; the + // key-bearing Street View fallback below has its own check. + if (admitSameSiteKeyless(req, res)) return; const sources = await getCctvSources(); const sourceById = new Map( sources.map((source) => [source.id, source]), ); - const url = new URL(req.url || '/', 'http://localhost'); if (url.pathname === '/sources') { const body = { - trimmedPacks: getCctvSources.trimmedPacks?.() ?? [], + trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [], sources: sources.map((source) => ({ id: source.id, pack: source.pack, @@ -164,6 +175,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { sourceKind: source.sourceKind || (source.url ? 'configured' : 'fallback'), poseSource: source.poseSource, + model: source.model || '', + manufacturer: source.manufacturer || '', license: source.license, credit: source.credit || '', code: source.code || '', @@ -214,6 +227,21 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { const source = sourceById.get(cameraId); const mediaUrl = source?.url || ''; const feedType = normalizeFeedType(source?.feedType || 'image'); + // Live feeds only. A still camera is served by /frame, whose fetch + // keeps redirects on the registered origin, accepts only raster + // images and caps the body; relaying it here would bypass all three. + if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') { + res.writeHead(404, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'No live media for this camera; use the frame endpoint', + }), + ); + return; + } const leaseId = url.searchParams.get('lease'); if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) { res.writeHead(400); @@ -365,7 +393,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } return; } - const contentType = upstream.headers.get('content-type') || ''; if (!upstream.ok) { try { await upstream.body?.cancel(); @@ -390,31 +417,42 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { return; } - if ( - isVideoFeedType(feedType) && - !( - contentType.startsWith('video/') || - contentType.includes('mpegurl') - ) - ) { + // The relayed type is the feed kind's own, checked before the + // camera counts as healthy and before any byte is relayed. + const contentType = cctvLiveMediaType( + feedType, + upstream.headers.get('content-type'), + ); + if (!contentType) { + try { + await upstream.body?.cancel(); + } catch { + /* already closed */ + } setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', label: source?.provider || 'Configured source', - message: `Unexpected media type ${contentType || 'unknown'}`, + message: 'Unexpected media type', }); - } else { - setHealth(cameraId, { - status: 'ok', - sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', - label: source?.provider || 'Configured source', - message: isVideoFeedType(feedType) - ? 'Live stream connected' - : 'Snapshot feed connected', + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', }); + res.end(JSON.stringify({ error: 'Media proxy failed' })); + return; } + setHealth(cameraId, { + status: 'ok', + sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', + label: source?.provider || 'Configured source', + message: isVideoFeedType(feedType) + ? 'Live stream connected' + : 'Snapshot feed connected', + }); await proxyMediaResponse(res, upstream, { + contentType, sourceHeader: isVideoFeedType(feedType) ? 'live-media' : 'upstream-image', @@ -507,13 +545,22 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { return; } - const sv = await streetViewFallback({ - lat, - lon, - heading, - fov, - pitch, - }); + // The Street View fallback spends the Google key, so a forwarded + // request (whose client this server cannot see) gets the placeholder, + // as if no key were configured. Any forwarding header counts, even an + // empty one. + const forwarded = PROXY_SIGNALS.some( + (name) => req.headers?.[name] !== undefined, + ); + const sv = forwarded + ? null + : await streetViewFallback({ + lat, + lon, + heading, + fov, + pitch, + }); if (sv?.ok) { setHealth(cameraId, { status: 'degraded', diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 9bc3b70ec..747aae4f8 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -4,6 +4,8 @@ import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js'; import { allocateSourceCap, resolveCatalogCap } from './cap.js'; import { loadGroundHeights, joinGroundHeights } from './groundHeights.js'; import { cameraRegion, normalizeSourceItem } from './normalize.js'; +import { loadLithuaniaSources } from './lithuania.js'; +import { loadTaiwanSources } from './taiwan.js'; import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, @@ -17,19 +19,32 @@ import { loadWarendorfSourcesFromCatalog, loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, + loadCataloniaSourcesFromOpenData, + loadHkSourcesFromOpenData, loadDelDOTSourcesFromOpenData, + loadKingCountySourcesFromOpenData, + loadWsdotSourcesFromOpenData, loadVegvesenSourcesFromOpenData, + loadNycSourcesFromOpenData, + loadTorontoSourcesFromOpenData, + loadFloridaSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; +/** Env opt-in: only "1" enables. For packs whose feed access needs an + * operator agreement or whose terms are unconfirmed, so an unset flag never + * reaches the upstream. */ +const envOptIn = (name) => String(process.env[name] || '').trim() === '1'; /** * Live open-data packs, in merge order. Adding a region is one entry here - * plus its loader in sources.js; the catalog cap is shared across entries - * round-robin (cap.js), so a new pack never silently evicts an older one. + * plus its loader (in sources.js or its own module); the catalog cap is + * shared across entries round-robin (cap.js), so a new pack never silently + * evicts an older one. * Each pack fails independently (allSettled) and is gated by its own env - * kill switch. + * kill switch, or by an opt-in flag where access needs an agreement or the + * terms are unconfirmed. */ const LIVE_PACKS = [ { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, @@ -88,16 +103,68 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CALGARY_ENABLED'), load: loadCalgarySourcesFromOpenData, }, + { + name: 'catalonia', + enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), + load: loadCataloniaSourcesFromOpenData, + }, + { + name: 'hk', + enabled: () => envEnabled('CCTV_HK_ENABLED'), + load: loadHkSourcesFromOpenData, + }, { name: 'deldot', enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), load: loadDelDOTSourcesFromOpenData, }, + { + name: 'kingcounty', + enabled: () => envEnabled('CCTV_KINGCOUNTY_ENABLED'), + load: loadKingCountySourcesFromOpenData, + }, + { + name: 'wsdot', + enabled: () => envEnabled('CCTV_WSDOT_ENABLED'), + load: loadWsdotSourcesFromOpenData, + }, { name: 'vegvesen', enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), load: loadVegvesenSourcesFromOpenData, }, + { + // NYC DOT requires a data-sharing agreement for this feed, so the pack is + // off unless the operator opts in (see DATA_SOURCES.md). + name: 'nyc', + enabled: () => envOptIn('CCTV_NYC_ENABLED'), + load: loadNycSourcesFromOpenData, + }, + { + name: 'toronto', + enabled: () => envEnabled('CCTV_TORONTO_ENABLED'), + load: loadTorontoSourcesFromOpenData, + }, + { + // Opt-in until the Via Lietuva open-data terms are confirmed to cover the + // camera photos (see DATA_SOURCES.md). + name: 'lithuania', + enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'), + load: loadLithuaniaSources, + }, + { + // FL511's terms limit its content to individual, personal use (other use + // needs FDOT's written consent), so the pack is off unless the operator + // opts in (see DATA_SOURCES.md). + name: 'florida', + enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'), + load: loadFloridaSourcesFromOpenData, + }, + { + name: 'taiwan', + enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'), + load: loadTaiwanSources, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). @@ -149,16 +216,19 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { /** @type {Promise>|null} In-flight refresh, shared by concurrent * callers so a post-TTL burst launches ONE refetch, not one per request. */ let _cctvSourceInflight = null; - /** @type {Array<{pack: string, available: number, served: number}>} Packs - * that serve fewer cameras than they offer. */ - let _cctvTrimmedPacks = []; + /** @type {WeakMap, Array<{pack: string, available: number, served: number}>>} + * Packs that serve fewer cameras than they offer, keyed by the source list + * they describe, so a request served the old list during a background + * refresh never pairs it with the new list's trim notes. */ + const _cctvTrimmedPacks = new WeakMap(); /** * Assemble and cache the merged CCTV source list. * * Merges every source pack (live open-data packs, local file, env * variable), deduplicates by ID, shares the catalog cap fairly across - * packs, and caches for CCTV_SOURCE_CACHE_MS. + * packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is + * returned while a background refresh replaces it; only a first load waits. * * @returns {Promise>} Deduplicated, capped source list. */ @@ -173,11 +243,31 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { // Single-flight: a burst of requests arriving past the TTL shares ONE refresh // instead of each launching the full multi-provider refetch. The `.finally` // clears the ref so the next post-TTL cycle starts fresh. - if (_cctvSourceInflight) return _cctvSourceInflight; - _cctvSourceInflight = refreshCctvSources().finally(() => { - _cctvSourceInflight = null; - }); - return _cctvSourceInflight; + if (!_cctvSourceInflight) { + _cctvSourceInflight = refreshCctvSources().finally(() => { + _cctvSourceInflight = null; + }); + // A background refresh has no caller waiting on it: report its failure + // here, once per refresh rather than once per request it serves. Keep + // the old catalog for another TTL, as an empty refresh does, so a refresh + // that keeps failing is retried once per TTL, not back to back. + if (_cctvSourceCache.length) { + _cctvSourceInflight.catch((error) => { + _cctvSourceCacheAt = Date.now(); + console.warn( + '[CCTV] background catalog refresh failed; serving', + _cctvSourceCache.length, + 'cameras from the last catalog:', + error?.message || error, + ); + }); + } + } + // With a catalog in hand, answer with it while the refresh runs: the + // refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up + // to its 30 s timeout), and every /api/cctv request would otherwise wait + // for it. Only the first load, with nothing to serve yet, waits. + return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight; } /** @@ -267,7 +357,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { } if (capped.length > 0 || _cctvSourceCache.length === 0) { _cctvSourceCache = capped; - _cctvTrimmedPacks = trimmedPacks; + _cctvTrimmedPacks.set(capped, trimmedPacks); } else { // Every source came back empty (all live packs timed out / upstream outage) // but a good catalog is already cached — serve it stale rather than blanking @@ -281,7 +371,10 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { return _cctvSourceCache; } - /** Packs serving fewer cameras than they offer, as of the last refresh. */ - getCctvSources.trimmedPacks = () => _cctvTrimmedPacks; + /** Packs serving fewer cameras than they offer in `sources` (by default the + * current catalog). Pass the list a request was served to keep the two in + * step while a background refresh swaps the catalog. */ + getCctvSources.trimmedPacks = (sources = _cctvSourceCache) => + _cctvTrimmedPacks.get(sources) ?? []; return getCctvSources; } diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 6298e86a0..a26fa6c20 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -8,8 +8,8 @@ export const DEFAULT_AUSTIN_MAX_SOURCES = 250; * Catalog-wide safety ceiling on served cameras. Each pack already caps * itself (nearest-to-anchor first); this bound only matters when the packs * together exceed it, and it is then filled round-robin across packs (see - * cap.js) so no region is silently dropped. Sized above the sum of the - * default per-pack caps so a default install never trims. + * cap.js) so no region is silently dropped. The default per-pack caps + * together can exceed it, so a default install may be thinned evenly here. */ export const DEFAULT_CCTV_MAX_SOURCES = 4000; /** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */ @@ -18,7 +18,7 @@ export const CCTV_MAX_SOURCES_CEILING = 5000; export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 }; /** Caltrans CCTV: one JSON feed per district, identical schema statewide. */ /** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */ -export const TXDOT_ORIGIN = 'https://its.txdot.gov'; +const TXDOT_ORIGIN = 'https://its.txdot.gov'; export const TXDOT_CCTV_STATUS_URL = (district) => `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`; /** Per-camera frame. Returns JSON `{snippet:}`, not an image body; @@ -113,13 +113,34 @@ export const CALTRANS_ANCHORS = [ { lat: 32.7157, lon: -117.1611 }, // San Diego { lat: 38.5816, lon: -121.4944 }, // Sacramento ]; +export const FLORIDA_CCTV_URL = + 'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query'; +export const FLORIDA_IMAGE_HOSTS = [ + 'images-dis.divas.cloud', + 'snapshots.divas.cloud', +]; +export const DEFAULT_FLORIDA_MAX_SOURCES = 500; +/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */ +export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */ +export const FLORIDA_MAX_CATALOG_PAGES = 40; +export const FLORIDA_ANCHORS = [ + { lat: 25.7617, lon: -80.1918 }, // Miami + { lat: 28.5384, lon: -81.3789 }, // Orlando + { lat: 27.9506, lon: -82.4572 }, // Tampa + { lat: 30.3322, lon: -81.6557 }, // Jacksonville + { lat: 30.4383, lon: -84.2807 }, // Tallahassee + { lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale + { lat: 26.7153, lon: -80.0534 }, // West Palm Beach + { lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle) +]; /** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */ export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam'; export const TFL_IMAGE_ORIGIN = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/'; export const DEFAULT_TFL_MAX_SOURCES = 250; export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 }; -/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */ +/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); frame URLs are still images. */ export const ONTARIO_511_CAMERAS_URL = 'https://511on.ca/api/v2/get/cameras?format=json&lang=en'; export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/'; @@ -188,6 +209,8 @@ export const TARKTEE_IMAGES_URL = 'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages'; export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/'; export const DEFAULT_TARKTEE_MAX_SOURCES = 179; +/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */ +export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024; export const TARKTEE_ANCHORS = [ { lat: 59.437, lon: 24.753 }, // Tallinn { lat: 58.378, lon: 26.729 }, // Tartu @@ -236,6 +259,86 @@ 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; +/** + * Catalonia traffic cameras (Servei Català de Trànsit): one keyless WFS/GML + * XML list (verified 2026-09-15, hourly catalog refresh), fetched over https + * (verified 2026-09-27: same 200 body, no redirect). The feed carries a + * `font` field naming the actual publisher per camera — SCT's own highway + * cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras are + * kept, each confirmed to return a real frame. Frame URLs are checked + * against a host allowlist rather than one pinned origin, since each + * publisher serves from its own host. + */ +export const CATALONIA_CAMERAS_URL = + 'https://www.gencat.cat/transit/opendata/cameres.xml'; +/** Byte ceiling for the Catalonia catalog read (~90 KB observed 2026-09-27), + * so a runaway body cannot be buffered without limit. */ +export const CATALONIA_MAX_CATALOG_BYTES = 1024 * 1024; +/** Allowed image hosts, one per kept `font` publisher in the feed, each + * mapped to the one directory its frame links live in (every link in the + * 2026-10 feed shares it); a link must name a single file directly inside. */ +export const CATALONIA_IMAGE_HOSTS = Object.freeze( + new Map([ + ['mct.gencat.cat', '/mct2bo/'], // SCT + ['www.bcn.cat', '/transit/imatges/'], // IMI (Ajuntament de Barcelona) + ['emap.terrassa.cat', '/it_terrassa/'], // Ajuntament de Terrassa + ]), +); +/** SCT `sctidcam` values: a bare image filename, which also keys the camera + * id (the feed's `fid` embeds an upstream server-start stamp). */ +export const CATALONIA_SCT_CAMERA_FILE_RE = /^[a-z0-9]+\.(gif|jpe?g)$/i; +/** Per-`font` partner credit, shown beside the provider in the CCTV panel + * (same pattern as DriveBC's partner-supplied cameras). SCT is the feed's + * own publisher and carries no separate credit. */ +export const CATALONIA_FONT_CREDIT = Object.freeze({ + IMI: 'Ajuntament de Barcelona', + Terrassa: 'Ajuntament de Terrassa', +}); +/** Ground-elevation priors in metres, by `font`: the feed carries no + * elevation, and this pack spans sea level (Barcelona) to Terrassa's plateau + * (~270 m). The client's ground snap corrects these on 3D-tile stacks. */ +export const CATALONIA_FONT_ELEVATION_M = Object.freeze({ + SCT: 80, + IMI: 20, + Terrassa: 270, +}); +export const DEFAULT_CATALONIA_MAX_SOURCES = 150; +/** Prioritization anchors: Barcelona and Terrassa. */ +export const CATALONIA_ANCHORS = [ + { lat: 41.3874, lon: 2.1686 }, // Barcelona + { lat: 41.5636, lon: 2.0111 }, // Terrassa +]; + +/** Hong Kong Transport Department traffic snapshot cameras: keyless English + * locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */ +export const DEFAULT_HK_CAMERAS_URL = + 'https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml'; +/** The only origin HK camera frames may come from. Verified 2026-10-11: still + * URLs answer 200 on this host with no redirect hop. */ +export const HK_IMAGE_ORIGIN = 'https://tdcctv.data.one.gov.hk/'; +/** Above the full TD English catalog (~1,013 cameras) so a default install + * keeps nearly all of them. Catalog-wide round-robin still applies when the + * sum of packs exceeds DEFAULT_CCTV_MAX_SOURCES (Norway-style). */ +export const DEFAULT_HK_MAX_SOURCES = 1100; +/** Prioritization anchors across Hong Kong Island, Kowloon and the New + * Territories so a lowered cap keeps territory-wide coverage rather than + * only the harbour corridor. */ +export const HK_ANCHORS = [ + { lat: 22.2819, lon: 114.158 }, // Central + { lat: 22.2976, lon: 114.1722 }, // Tsim Sha Tsui + { lat: 22.3827, lon: 114.19 }, // Sha Tin + { lat: 22.3701, lon: 114.114 }, // Tsuen Wan + { lat: 22.4503, lon: 114.1688 }, // Tai Po + { lat: 22.493, lon: 114.139 }, // Fanling + { lat: 22.391, lon: 113.977 }, // Tuen Mun + { lat: 22.4445, lon: 114.022 }, // Yuen Long + { lat: 22.307, lon: 114.26 }, // Tseung Kwan O +]; +/** Hard ceiling on the locations XML body. The live English list is ~400 KB + * for ~1,000 cameras; this only exists so an unbounded upstream cannot be + * buffered without limit. */ +export const HK_MAX_CATALOG_BYTES = 4 * 1024 * 1024; + /** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view * of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole * country (~900 cameras). The raw DATEX node needs an account; this view does @@ -249,9 +352,9 @@ export const VEGVESEN_IMAGE_ORIGIN = * per-camera manifest path is registered. */ export const VEGVESEN_VIDEO_URL = (cameraId) => `https://kamera.vegvesen.no/public/${cameraId}/manifest.m3u8`; -/** Above the ~850 working cameras so a default install keeps them all, while - * staying close to them: the per-pack defaults are meant to sum to no more - * than DEFAULT_CCTV_MAX_SOURCES. */ +/** Above the ~850 working cameras so the pack keeps them all on its own. The + * catalog-wide DEFAULT_CCTV_MAX_SOURCES cap still applies across packs and is + * filled round-robin, so a full default install may thin this pack. */ export const DEFAULT_VEGVESEN_MAX_SOURCES = 900; /** Hard ceiling on the catalog body. The whole country is ~800 KB. */ export const VEGVESEN_MAX_CATALOG_BYTES = 8 * 1024 * 1024; @@ -267,6 +370,79 @@ export const NORWAY_ANCHORS = [ { lat: 69.6492, lon: 18.9553 }, // Tromsø ]; +/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above + * sea level (NN2000), used as the Vegvesen cameras' ground prior because the + * camera feed's points are 2D. */ +export const KARTVERKET_HEIGHT_URL = + 'https://ws.geonorge.no/hoydedata/v1/punkt'; +/** Points per request. The API takes up to 50, but above ~20 it answers the + * extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not + * at all; at 20 nearly every camera resolves from the 1 m terrain model. */ +export const KARTVERKET_MAX_POINTS = 20; +/** Requests in flight at once. The whole country is ~36 requests of + * 0.6-9.5 s each, so a cold lookup takes ~6-13 s. */ +export const KARTVERKET_CONCURRENCY = 8; +/** Deadline for one complete lookup, shared by every request. A lookup cut + * short by it is resumed on the next refresh, without a backoff. */ +export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000; +/** One request that takes longer than this counts as a failing service. */ +export const KARTVERKET_REQUEST_TIMEOUT_MS = 10 * 1000; +/** The Vegvesen lane's budget, measured from the start of the loader (catalog + * fetch included), for waiting on heights. Under the 15 s per-provider fetch + * timeout so this pack is never the catalog's slowest lane. A lookup still + * running after it keeps filling the cache for the next refresh. */ +export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000; +/** The lane only waits when more than this share of its cameras has no + * answer yet (a cold cache). A few new cameras or expired misses are looked + * up in the background instead of holding the refresh. */ +export const KARTVERKET_WAIT_MISSING_SHARE = 0.1; +/** A point Kartverket has no height for (some ferry quays and bridges) is not + * asked about again for this long. */ +export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000; +/** After a failing request (408, 429, 5xx, a network error, a request + * timeout or an unreadable body), no lookup starts for this long, or for the + * Retry-After the service sent, bounded to [1 min, 1 h]. Longer than the + * 15-minute catalog refresh (CCTV_SOURCE_CACHE_MS), so a failing service + * costs one attempt per backoff, not one per refresh. */ +export const KARTVERKET_FAILURE_BACKOFF_MS = 30 * 60 * 1000; +export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000; +export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024; +/** Plausible Norwegian terrain: below sea level only at the shore, and no road + * camera sits above ~1,500 m. Anything outside is treated as no height. */ +export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 }); +/** Same identity the other CCTV upstream requests send. */ +export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0'; + +/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the + * five boroughs (~980 cameras). Frames are stills synthesized from each + * camera UUID on the city's webcam host — never taken from the payload. */ +export const NYC_CAMERAS_URL = 'https://webcams.nyctmc.org/api/cameras'; +/** The only origin NYC camera frames may come from. The catalog's `imageUrl` + * is ignored; the registered path is always this origin plus a validated + * UUID plus `/image`. */ +export const NYC_IMAGE_ORIGIN = 'https://webcams.nyctmc.org/api/cameras/'; +export const NYC_IMAGE_URL = (id) => `${NYC_IMAGE_ORIGIN}${id}/image`; +export const NYC_CAMERA_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const DEFAULT_NYC_MAX_SOURCES = 250; +/** Hard ceiling on the NYC catalog body. The live list is ~980 rows / ~250 KB; + * this only exists so an upstream that streams an unbounded body cannot be + * buffered without limit. */ +export const NYC_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** Ground-elevation prior in metres. Matches the New York CITY_POIS prior; + * the client's one-shot ground snap corrects it wherever 3D tiles load. */ +export const NYC_GROUND_ELEVATION_M = 10; +/** Prioritization anchors: one per borough so a cap keeps citywide coverage + * rather than Midtown alone. */ +export const NYC_ANCHORS = [ + { lat: 40.758, lon: -73.9855 }, // Manhattan (Times Square) + { lat: 40.7128, lon: -74.006 }, // Lower Manhattan + { lat: 40.6782, lon: -73.9442 }, // Brooklyn + { lat: 40.7282, lon: -73.7949 }, // Queens + { lat: 40.8448, lon: -73.8648 }, // Bronx + { lat: 40.5795, lon: -74.1502 }, // Staten Island +]; + /** 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; @@ -275,6 +451,83 @@ export const DELDOT_ANCHORS = [ { lat: 39.1582, lon: -75.5244 }, // Dover (Kent) { lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex) ]; + +/** King County, WA road cameras: keyless public ArcGIS FeatureServer layer + * ("King County and WSDOT Traffic Cameras", King County DOT Road Services). + * GeoJSON with WGS84 point geometry. `outFields` is an explicit list on + * purpose: the layer also carries asset-administration columns (including + * staff names in `ModifiedBy`) that must never be fetched. Uniquely among the + * packs, the county publishes each camera's hardware `Manufacturer`/`Model` + * (Cohu 3950/3965, Bosch AUTODOME, Axis Q6135-LE, …), carried through as the + * source's `model`. */ +export const KING_COUNTY_CAMERAS_URL = + 'https://services.arcgis.com/Ej0PsM5Aw677QF1W/arcgis/rest/services/TRAFFICCAMERA_POINT_2029/FeatureServer/0/query?where=1%3D1&outFields=AssetID,Location,ImageURL,Live,Model,Manufacturer,Owner,CurrentStatus&f=geojson'; +/** Frames come only from the county's own image handler host. */ +export const KING_COUNTY_IMAGE_ORIGIN = 'https://info.kingcounty.gov/'; +/** The whole catalog is ~125 rows (~59 live county cameras); no thinning by + * default. */ +export const DEFAULT_KING_COUNTY_MAX_SOURCES = 125; +/** Prioritization anchor: Renton, roughly central to the county road network. */ +export const KING_COUNTY_ANCHORS = [{ lat: 47.4829, lon: -122.2171 }]; +/** Hard ceiling on the King County catalog body. The whole layer is ~125 rows + * and well under 100 KB; this only exists so an upstream that streams an + * unbounded body cannot be buffered without limit. */ +export const KING_COUNTY_MAX_CATALOG_BYTES = 1024 * 1024; +/** + * City of Toronto RESCU traffic cameras: the municipal network the province's + * 511 pack does not cover — downtown arterials plus the Gardiner, DVP, Allen + * and Lake Shore. One keyless list for the whole city (~336 cameras), from the + * City's open data portal. It is fetched on every catalog refresh, but the + * upstream file is not updated often and may lag the live camera network. + * + * The list is served as JSONP (`jsonTMCEarthCamerasCallback({...})`); the + * portal publishes the same data as .xml and .csv beside it. + */ +export const TORONTO_RESCU_LIST_URL = + 'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json'; +/** + * The only origin Toronto camera frames may come from. The list carries no URL + * field: frame URLs are BUILT from this origin and a strictly-digits camera + * number (`loc####.jpg`, the convention the dataset documents), so no upstream + * field can steer the frame proxy off-host. + */ +export const TORONTO_RESCU_IMAGE_ORIGIN = + 'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/'; +/** The whole city fits under this; the cap exists to stay tunable, not to trim. */ +export const DEFAULT_TORONTO_MAX_SOURCES = 336; +/** Yonge / Queen: the prioritization anchor. */ +export const TORONTO_DOWNTOWN = { lat: 43.6532, lon: -79.3832 }; +/** The city climbs from 76 m at the lake to 209 m at Steeles, so no single + * number fits: this is the mean across the 336 camera sites. The ground-height + * sidecar has no Toronto entries yet, so this prior seeds placement; a camera + * that gains a sidecar entry uses it instead, and the client's ground snap + * corrects the prior wherever 3D tiles are loaded. */ +export const TORONTO_GROUND_ELEVATION_M = 116; +/** Hard ceiling on the RESCU list body. The whole city is ~336 rows and under + * 60 KB; this only exists so an upstream that streams an unbounded body cannot + * be buffered without limit. */ +export const TORONTO_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** WSDOT Travel Center cameras (Washington State): keyless Esri JSON on the + * official open-data host — ~1,700 point features in EPSG:3857 (Web Mercator), + * ETag/Last-Modified served. WSDOT's metadata marks the feed "low volume" use; + * the catalog is fetched once per CCTV_SOURCE_CACHE_MS refresh. */ +export const WSDOT_CAMERAS_URL = + 'https://data.wsdot.wa.gov/travelcenter/Cameras.json'; +/** Only frames on WSDOT's own image host are registered; the catalog also + * lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) + * whose own terms apply. */ +export const WSDOT_IMAGE_ORIGIN = 'https://images.wsdot.wa.gov/'; +export const DEFAULT_WSDOT_MAX_SOURCES = 250; +/** Hard ceiling on the WSDOT catalog body. The live layer is ~400 KB; this + * only exists so an upstream that streams an unbounded body cannot be + * buffered without limit. */ +export const WSDOT_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** Prioritization anchors: downtown Seattle and Spokane. */ +export const WSDOT_ANCHORS = [ + { lat: 47.6062, lon: -122.3321 }, // Seattle + { lat: 47.6588, lon: -117.426 }, // Spokane +]; + /** 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 @@ -282,6 +535,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; * pending forever — a hung fetch aborts, the loader returns [], and * serve-stale/other packs take over. */ export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000; +/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across + * ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian + * pack on about a third of loads. Both must arrive for any camera to load. */ +export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000; /** Individual CCTV image fetches must settle before the active 10-second * client refresh cadence. A bounded miss can fall through to Street View or * the synthetic frame instead of leaving the browser preview pending. */ diff --git a/server/providers/cctv/groundHeights.js b/server/providers/cctv/groundHeights.js index c73b6e840..f20b9758d 100644 --- a/server/providers/cctv/groundHeights.js +++ b/server/providers/cctv/groundHeights.js @@ -3,7 +3,7 @@ import path from 'node:path'; import { poseHash } from '../../../src/data/cctvFootprint.js'; /** Sidecar written by scripts/precompute-cctv-heights.mjs. */ -export const DEFAULT_GROUND_HEIGHTS_FILE = +const DEFAULT_GROUND_HEIGHTS_FILE = 'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json'; /** @type {{path:string, mtimeMs:number, cameras:object}|null} */ diff --git a/server/providers/cctv/lithuania.js b/server/providers/cctv/lithuania.js new file mode 100644 index 000000000..840e68c6c --- /dev/null +++ b/server/providers/cctv/lithuania.js @@ -0,0 +1,154 @@ +import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js'; +import { + cameraDisplayCode, + fallbackHeadingFromId, + prioritizeSources, +} from './normalize.js'; +import { readResponseJsonCapped } from '../common/http.js'; + +const BASE = 'https://eismoinfo.lt/eismoinfo-backend'; +export const LITHUANIA_CAMERAS_URL = `${BASE}/camera-info-table`; +// Without lks=true this endpoint returns [latitude, longitude]. The camera +// table's x/y fields use LKS-94 metres and must never be treated as degrees. +export const LITHUANIA_LOCATIONS_URL = `${BASE}/layer-static-features/VKR`; +export const LITHUANIA_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +const FRAME_PATH = '/eismoinfo-backend/image-provider/camera/last'; +const ANCHORS = [ + { lat: 54.6872, lon: 25.2797 }, // Vilnius + { lat: 54.8985, lon: 23.9036 }, // Kaunas + { lat: 55.7033, lon: 21.1443 }, // Klaipeda + { lat: 55.9349, lon: 23.3137 }, // Siauliai + { lat: 55.7348, lon: 24.3575 }, // Panevezys +]; + +/** Join public catalogs by ID; register only the official still endpoint. */ +export function lithuaniaCameraToSource(row, feature) { + const id = String(row?.id ?? ''); + if (!/^[1-9]\d{0,8}$/.test(id) || String(feature?.id) !== id) return null; + const point = feature?.points?.[0]?.point; + if (!Array.isArray(point) || point.length !== 2) return null; + const [lat, lon] = point; + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + lat < 53.8 || + lat > 56.5 || + lon < 20.8 || + lon > 26.9 + ) + return null; + try { + const url = new URL(row.image); + if ( + url.origin !== 'https://eismoinfo.lt' || + url.username || + url.password || + url.pathname !== FRAME_PATH || + url.searchParams.get('id') !== id + ) + return null; + } catch { + return null; + } + // Reconstruct so extra query parameters cannot alter the request. + const imageUrl = `https://eismoinfo.lt${FRAME_PATH}?id=${id}`; + const cameraId = `lithuania-${id}`; + const name = String(row.name || feature.name || `Lietuva ${id}`) + .trim() + .slice(0, 160); + return { + id: cameraId, + name, + city: 'Lithuania / Lietuva', + cityId: 'lithuania', + provider: 'Via Lietuva / Eismoinfo', + lat, + lon, + // No calibrated pose is published; use the app's low-confidence prior. + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'eismoinfo-public', + credit: + 'Via Lietuva / eismoinfo.lt — periodically updated road-camera stills', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +async function readCatalog(url, sibling) { + const signal = AbortSignal.any([ + AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + sibling.signal, + ]); + try { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal, + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error( + response.status >= 300 && response.status < 400 + ? 'catalog redirected; redirects are not followed' + : `Catalog HTTP ${response.status}`, + ); + } + return await readResponseJsonCapped( + response, + LITHUANIA_MAX_CATALOG_BYTES, + signal, + ); + } catch (error) { + // The join needs both catalogs; release the other request at once + // rather than leaving its body open until the timeout. + sibling.abort(); + throw error; + } +} + +/** Two bounded requests per shared catalog refresh, no API key required. */ +export async function loadLithuaniaSources() { + try { + const sibling = new AbortController(); + const [rows, layers] = await Promise.all([ + readCatalog(LITHUANIA_CAMERAS_URL, sibling), + readCatalog(LITHUANIA_LOCATIONS_URL, sibling), + ]); + if (!Array.isArray(rows) || !Array.isArray(layers)) return []; + const features = layers.find((layer) => layer?.layer === 'VKR')?.features; + if (!Array.isArray(features)) return []; + const locations = new Map( + features.filter(Boolean).map((feature) => [String(feature.id), feature]), + ); + const cameras = new Map(); + for (const row of rows) { + const camera = lithuaniaCameraToSource( + row, + locations.get(String(row?.id)), + ); + if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera); + } + const rawCap = Number(process.env.CCTV_LITHUANIA_MAX_SOURCES || 400); + const cap = Number.isFinite(rawCap) + ? Math.max(1, Math.min(1000, Math.floor(rawCap))) + : 400; + const sources = prioritizeSources([...cameras.values()], cap, ANCHORS); + console.log( + `[CCTV] Loaded Lithuania camera sources: ${cameras.size} (using ${sources.length})`, + ); + return sources; + } catch (error) { + console.warn( + '[CCTV] Lithuania camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 549e6cf06..206a24888 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -1,13 +1,21 @@ import { Readable } from 'node:stream'; -import { hashSeed, escapeXml } from './normalize.js'; +import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js'; import { + CALGARY_IMAGE_ORIGIN, 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, + HK_IMAGE_ORIGIN, + KING_COUNTY_IMAGE_ORIGIN, NSW_IMAGE_ORIGIN, NSW_IMAGE_USER_AGENT, + TALLINN_IMAGE_ORIGIN, + TARKTEE_IMAGE_ORIGIN, + TFL_IMAGE_ORIGIN, + WARENDORF_IMAGE_ORIGINS, + WSDOT_IMAGE_ORIGIN, } from './constants.js'; /** * Generate a synthetic SVG billboard image for a CCTV camera placeholder. @@ -84,7 +92,7 @@ export function buildSyntheticCctvSvg({ cameraId, label, city, status }) { * @param {ReadableStream|NodeJS.ReadableStream|null} body * @returns {import('stream').Readable|null} */ -export function toReadable(body) { +function toReadable(body) { if (!body) return null; if (typeof body.pipe === 'function') return body; if (typeof body.getReader === 'function') { @@ -93,6 +101,73 @@ export function toReadable(body) { return null; } +/** + * Split a Content-Type value into its lower-case essence and parameters. + * + * @param {string|null|undefined} value + * @returns {{essence:string,params:Map}|null} Null when the + * value is missing or is not a `type/subtype` media type. + */ +function parseMediaType(value) { + const [head, ...rest] = String(value ?? '').split(';'); + const essence = head.trim().toLowerCase(); + if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null; + const params = new Map(); + for (const part of rest) { + const eq = part.indexOf('='); + if (eq <= 0) continue; + const name = part.slice(0, eq).trim().toLowerCase(); + let paramValue = part.slice(eq + 1).trim(); + if (/^".*"$/.test(paramValue)) paramValue = paramValue.slice(1, -1); + if (name && !params.has(name)) params.set(name, paramValue); + } + return { essence, params }; +} + +/** Still-image types a camera frame may be served as. */ +const CCTV_RASTER_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', +]); + +/** + * The canonical type of a raster camera frame, or null for any other type. + * + * @param {string|null|undefined} contentType + * @returns {string|null} + */ +export function cctvRasterImageType(contentType) { + const essence = parseMediaType(contentType)?.essence; + return CCTV_RASTER_IMAGE_TYPES.has(essence) ? essence : null; +} + +/** + * The type to relay a live feed as, or null when the upstream type does not + * match the feed kind. MJPEG keeps its part boundary, without which the body + * cannot be split into frames. + * + * @param {string} feedType - Normalized feed type (mp4, webm or mjpeg). + * @param {string|null|undefined} contentType + * @returns {string|null} + */ +export function cctvLiveMediaType(feedType, contentType) { + const parsed = parseMediaType(contentType); + if (!parsed) return null; + if (feedType === 'mp4' || feedType === 'webm') + return parsed.essence === `video/${feedType}` ? parsed.essence : null; + if (feedType !== 'mjpeg' || parsed.essence !== 'multipart/x-mixed-replace') + return null; + const boundary = parsed.params.get('boundary') || ''; + if (!/^[0-9A-Za-z'()+_,./:=? -]{0,69}[0-9A-Za-z'()+_,./:=?-]$/.test(boundary)) + return null; + return /^[0-9A-Za-z'+_.-]+$/.test(boundary) + ? `multipart/x-mixed-replace; boundary=${boundary}` + : `multipart/x-mixed-replace; boundary="${boundary}"`; +} + /** * Pipe an upstream fetch Response (image or video) to the client HTTP response. * @@ -104,6 +179,8 @@ 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 {string} [opts.contentType] - Validated type to send instead of the + * upstream's own. * @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. @@ -114,10 +191,10 @@ export async function proxyMediaResponse( { sourceHeader = 'upstream', idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS, + contentType = upstream.headers.get('content-type') || + 'application/octet-stream', } = {}, ) { - const contentType = - upstream.headers.get('content-type') || 'application/octet-stream'; const cacheControl = upstream.headers.get('cache-control') || 'no-store'; const contentLength = upstream.headers.get('content-length'); const contentRange = upstream.headers.get('content-range'); @@ -340,7 +417,14 @@ export async function fetchCctvMediaUpstream( if (downstream?.aborted) controller.abort(); else downstream?.addEventListener?.('abort', onDownstreamAbort); try { - return await fetchImpl(url, { headers, signal: controller.signal }); + // Redirects stay on the registered origin, as for frames. + const upstream = await fetchWithinHost( + url, + { headers, signal: controller.signal }, + fetchImpl, + ); + if (!upstream) throw new Error('Media redirect refused'); + return upstream; } finally { clearTimeout(timeoutId); downstream?.removeEventListener?.('abort', onDownstreamAbort); @@ -428,9 +512,40 @@ export async function fetchTxdotSnapshot( /** Redirect hops the frame path will follow, and only within the same host. */ const MAX_SAME_HOST_REDIRECTS = 2; +/** + * Directories camera catalogs are pinned to, by host. A redirect on one of + * these hosts must stay inside the pinned directory too, so a same-origin + * redirect cannot leave it (another bucket on a shared storage host). + */ +const CCTV_PINNED_PREFIXES_BY_HOST = new Map(); +for (const prefix of [ + TFL_IMAGE_ORIGIN, + TALLINN_IMAGE_ORIGIN, + TARKTEE_IMAGE_ORIGIN, + ...WARENDORF_IMAGE_ORIGINS, + NSW_IMAGE_ORIGIN, + CALGARY_IMAGE_ORIGIN, + HK_IMAGE_ORIGIN, + KING_COUNTY_IMAGE_ORIGIN, + WSDOT_IMAGE_ORIGIN, +]) { + const host = new URL(prefix).hostname; + CCTV_PINNED_PREFIXES_BY_HOST.set(host, [ + ...(CCTV_PINNED_PREFIXES_BY_HOST.get(host) || []), + prefix, + ]); +} + +/** Whether a redirect target stays inside its host's pinned directory. */ +function withinPinnedPrefix(url) { + const prefixes = CCTV_PINNED_PREFIXES_BY_HOST.get(url.hostname); + return !prefixes || prefixes.some((p) => pinnedCameraUrl(url.href, p)); +} + /** * Fetch a registered frame URL following redirects ONLY within the original - * origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops). + * origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops) and, + * on a pinned camera host, within its pinned directory. * Default redirect-following would let an upstream steer a host-pinned * request, and its host-specific headers, to any origin, another port, or a * plaintext downgrade. @@ -471,7 +586,7 @@ export async function fetchWithinHost(url, init, fetchImpl = fetch) { } catch { return null; } - if (next.origin !== origin) return null; + if (next.origin !== origin || !withinPinnedPrefix(next)) return null; current = next; } return null; @@ -543,9 +658,15 @@ export async function fetchCctvImageFromUpstream( fetchImpl, ); if (!upstream) return null; - const contentType = upstream.headers.get('content-type') || ''; - if (!upstream.ok || !contentType.startsWith('image/')) { - controller.abort(); + const contentType = cctvRasterImageType( + upstream.headers.get('content-type'), + ); + if (!upstream.ok || !contentType) { + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } return null; } const body = await readCappedResponseBytes(upstream, maxBytes); diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e62ba33f3..f190f257e 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -39,11 +39,21 @@ export function escapeXml(text) { /** * Coerce a value to a finite number, returning fallback if NaN/Infinity. * + * Absent, blank and boolean values take the fallback too: `Number` reads + * null, '' and false as 0, which would turn a missing coordinate, heading or + * elevation into a real zero. + * * @param {*} value * @param {number} [fallback=NaN] * @returns {number} */ export function toFiniteNumber(value, fallback = NaN) { + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return fallback; const num = Number(value); return Number.isFinite(num) ? num : fallback; } @@ -54,7 +64,7 @@ export function toFiniteNumber(value, fallback = NaN) { * @param {string} text * @returns {string} */ -export function normalizeKey(text) { +function normalizeKey(text) { return String(text || '') .trim() .toLowerCase() @@ -70,7 +80,7 @@ export function normalizeKey(text) { * @param {string} value * @returns {{lat:number, lon:number}} */ -export function parsePointString(value) { +function parsePointString(value) { const match = String(value || '').match( /POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i, ); @@ -90,7 +100,7 @@ export function parsePointString(value) { * @param {string|object|null} value * @returns {{lat:number, lon:number}} */ -export function coerceLatLon(value) { +function coerceLatLon(value) { if (!value) return { lat: NaN, lon: NaN }; if (typeof value === 'string') { @@ -367,6 +377,39 @@ export function isLikelyCalgaryCoordinate(lat, lon) { ); } +/** Florida statewide, Key West to Georgia state line, with slack at each edge */ +export function isLikelyFloridaCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 24.3 && + lat <= 31.1 && + lon >= -87.7 && + lon <= -79.8 + ); +} + +/** New York City's five boroughs, with slack for the harbor and city line. */ +export function isLikelyNycCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 40.45 && + lat <= 40.95 && + lon >= -74.3 && + lon <= -73.65 + ); +} + +/** Hong Kong SAR extent, with slack for the outlying islands. */ +export function isLikelyHongKongCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 22.15 && + lat <= 22.6 && + lon >= 113.8 && + lon <= 114.45 + ); +} + /** Mainland Norway, with slack for the coast and the Finnmark border. */ export function isLikelyNorwayCoordinate(lat, lon) { return ( @@ -383,6 +426,44 @@ export function isLikelyFinlandCoordinate(lat, lon) { return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; } +/** Catalonia bounding box (mainland is roughly 40.5..42.9 N, 0.15..3.4 E). */ +export function isLikelyCataloniaCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 40.3 && + lat <= 42.9 && + lon >= 0.0 && + lon <= 3.4 + ); +} + +/** + * Decode numeric HTML character references, decimal ("í") and hex + * ("í") alike, the entity form the Catalonia feed uses for accented + * place names, plus the handful of named entities XML/HTML text commonly + * carries. A reference that names no valid character (NUL, a surrogate, or + * anything past U+10FFFF) is left as written rather than thrown on, so one + * bad field cannot fail a whole catalog. + * + * @param {string} text + * @returns {string} + */ +export function decodeNumericEntities(text) { + return String(text || '') + .replace(/&#(?:x([0-9a-f]+)|(\d+));/gi, (match, hex, dec) => { + const code = hex ? Number.parseInt(hex, 16) : Number(dec); + const valid = + code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff); + return valid ? String.fromCodePoint(code) : match; + }) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .trim(); +} + /** * Human label for one Fintraffic preset (camera view). * @@ -551,5 +632,65 @@ export function normalizeSourceItem(item) { // badge can distinguish them from raw automated priors (e.g. Austin Open // Data, which never sets this field). Passed through as-is to the client. poseSource: item.poseSource === 'curated' ? 'curated' : undefined, + // Optional hardware-model passthrough (additive, like poseSource): packs + // whose catalog publishes the camera's hardware model — King County's + // layer carries Manufacturer/Model per camera — pass it through so + // identified-hardware consumers can use it. Absent everywhere else. + model: + typeof item.model === 'string' && item.model.trim() + ? item.model.trim() + : undefined, + // Bare model strings collide across vendors (e.g. "3950"), so the + // publishing pack's manufacturer rides along for disambiguation. + manufacturer: + typeof item.manufacturer === 'string' && item.manufacturer.trim() + ? item.manufacturer.trim() + : undefined, }; } + +/** City of Toronto bounding box (Steeles to the lake, Etobicoke to Scarborough). */ +export function isLikelyTorontoCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 43.55 && + lat <= 43.9 && + lon >= -79.7 && + lon <= -79.1 + ); +} + +/** + * A camera URL under a pinned directory, by parsed URL rather than string + * prefix: the same scheme and exact host as `prefix`, no credentials, the + * default port, and a normalized path inside the prefix's directory. Dot + * segments (including encoded ones) and backslashes are resolved before the + * path is compared, and an encoded slash or backslash is refused. + * + * @param {string} raw - Camera URL from a catalog or redirect. + * @param {string} prefix - Pinned origin and directory, ending in `/`. + * @returns {string|null} The normalized URL, or null when it is not pinned. + */ +export function pinnedCameraUrl(raw, prefix) { + let url; + let pin; + try { + url = new URL(String(raw ?? '').trim()); + pin = new URL(prefix); + } catch { + return null; + } + if ( + url.protocol !== pin.protocol || + url.hostname !== pin.hostname || + url.username || + url.password || + url.port || + !url.pathname.startsWith( + pin.pathname.endsWith('/') ? pin.pathname : `${pin.pathname}/`, + ) || + /%2f|%5c/i.test(url.pathname) + ) + return null; + return url.href; +} diff --git a/server/providers/cctv/ontario511.test.mjs b/server/providers/cctv/ontario511.test.mjs new file mode 100644 index 000000000..51334b3cb --- /dev/null +++ b/server/providers/cctv/ontario511.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + _resetOntarioKeyWarningForTest, + loadOntarioSourcesFromOpenData, +} from './sources.js'; + +const ROW = { + Id: '42', + Latitude: 43.6532, + Longitude: -79.3832, + Location: 'Hwy 401 near Yonge St', + Direction: 'Eastbound', + Views: [ + { + Status: 'Enabled', + Url: 'https://511on.ca/map/Cctv/4201', + Description: 'Eastbound', + }, + ], +}; + +function captureConsole(t) { + const lines = []; + const capture = (...args) => lines.push(args.join(' ')); + t.mock.method(console, 'warn', capture); + t.mock.method(console, 'log', capture); + return lines; +} + +test.beforeEach(() => _resetOntarioKeyWarningForTest()); + +test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => { + const lines = captureConsole(t); + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches += 1; + return Response.json([ROW]); + }); + assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); + assert.deepEqual( + await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }), + [], + ); + assert.equal(fetches, 0); + assert.equal(lines.length, 1); + assert.match(lines[0], /ONTARIO_511_API_KEY/); +}); + +test('with a key the camera list is requested with it and parsed', async (t) => { + const lines = captureConsole(t); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return Response.json([ROW]); + }); + const cameras = await loadOntarioSourcesFromOpenData( + { sourceRoot: '/ignored' }, + { ONTARIO_511_API_KEY: ' test-key ' }, + ); + assert.equal(requested.length, 1); + const url = new URL(requested[0]); + assert.equal(url.host, '511on.ca'); + assert.equal(url.searchParams.get('key'), 'test-key'); + assert.equal(url.searchParams.get('format'), 'json'); + assert.equal(cameras.length, 1); + assert.equal(cameras[0].id, 'on-42'); + assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201'); + // The records go to the browser through /api/cctv; the key must stay on + // the server-side catalog request. + assert.doesNotMatch(JSON.stringify(cameras), /test-key/); + assert.doesNotMatch(lines.join('\n'), /test-key/); +}); + +test('the source cap is read from the same environment as the key', async (t) => { + captureConsole(t); + const rows = Array.from({ length: 12 }, (_, i) => ({ + ...ROW, + Id: String(100 + i), + Latitude: ROW.Latitude + i * 0.01, + Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }], + })); + t.mock.method(globalThis, 'fetch', async () => Response.json(rows)); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' }, + ); + assert.equal(cameras.length, 8); +}); + +for (const status of [400, 401, 403]) { + test(`a ${status} points at the key by name, never by value`, async (t) => { + const lines = captureConsole(t); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ Message: 'Invalid Key' }, { status }), + ); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'secret-key-value' }, + ); + assert.deepEqual(cameras, []); + assert.match( + lines.join('\n'), + new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`), + ); + assert.doesNotMatch(lines.join('\n'), /secret-key-value/); + }); +} + +for (const status of [429, 500, 503]) { + test(`a ${status} is reported without blaming the key`, async (t) => { + const lines = captureConsole(t); + t.mock.method(globalThis, 'fetch', async () => + Response.json({}, { status }), + ); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'secret-key-value' }, + ); + assert.deepEqual(cameras, []); + assert.equal(lines.length, 1); + assert.equal( + lines[0], + `[CCTV] Ontario 511 camera download failed: ${status}`, + ); + assert.doesNotMatch(lines[0], /secret-key-value/); + }); +} diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..a479a945c 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -42,6 +42,7 @@ import { TARKTEE_IMAGES_URL, TARKTEE_IMAGE_ORIGIN, DEFAULT_TARKTEE_MAX_SOURCES, + TARKTEE_MAX_CATALOG_BYTES, TARKTEE_ANCHORS, DEFAULT_WARENDORF_SOURCE_FILE, WARENDORF_IMAGE_ORIGINS, @@ -55,16 +56,72 @@ import { DEFAULT_CALGARY_MAX_SOURCES, CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, + FLORIDA_CCTV_URL, + FLORIDA_IMAGE_HOSTS, + DEFAULT_FLORIDA_MAX_SOURCES, + FLORIDA_ANCHORS, + FLORIDA_MAX_CATALOG_BYTES, + FLORIDA_MAX_CATALOG_PAGES, + CATALONIA_CAMERAS_URL, + CATALONIA_MAX_CATALOG_BYTES, + CATALONIA_IMAGE_HOSTS, + CATALONIA_SCT_CAMERA_FILE_RE, + CATALONIA_FONT_CREDIT, + CATALONIA_FONT_ELEVATION_M, + DEFAULT_CATALONIA_MAX_SOURCES, + CATALONIA_ANCHORS, + TORONTO_RESCU_LIST_URL, + TORONTO_RESCU_IMAGE_ORIGIN, + DEFAULT_TORONTO_MAX_SOURCES, + TORONTO_DOWNTOWN, + TORONTO_GROUND_ELEVATION_M, + TORONTO_MAX_CATALOG_BYTES, + DEFAULT_HK_CAMERAS_URL, + HK_IMAGE_ORIGIN, + DEFAULT_HK_MAX_SOURCES, + HK_ANCHORS, + HK_MAX_CATALOG_BYTES, CCTV_SOURCE_FETCH_TIMEOUT_MS, + TARKTEE_FETCH_TIMEOUT_MS, DELDOT_CCTV_URL, DEFAULT_DELDOT_MAX_SOURCES, DELDOT_ANCHORS, + KING_COUNTY_CAMERAS_URL, + KING_COUNTY_IMAGE_ORIGIN, + DEFAULT_KING_COUNTY_MAX_SOURCES, + KING_COUNTY_ANCHORS, + KING_COUNTY_MAX_CATALOG_BYTES, + WSDOT_CAMERAS_URL, + WSDOT_IMAGE_ORIGIN, + DEFAULT_WSDOT_MAX_SOURCES, + WSDOT_ANCHORS, + WSDOT_MAX_CATALOG_BYTES, DEFAULT_VEGVESEN_CCTV_URL, VEGVESEN_IMAGE_ORIGIN, VEGVESEN_VIDEO_URL, DEFAULT_VEGVESEN_MAX_SOURCES, VEGVESEN_MAX_CATALOG_BYTES, NORWAY_ANCHORS, + KARTVERKET_HEIGHT_URL, + KARTVERKET_MAX_POINTS, + KARTVERKET_CONCURRENCY, + KARTVERKET_LOOKUP_TIMEOUT_MS, + KARTVERKET_REQUEST_TIMEOUT_MS, + KARTVERKET_WAIT_MISSING_SHARE, + KARTVERKET_CATALOG_WAIT_MS, + KARTVERKET_NULL_TTL_MS, + KARTVERKET_FAILURE_BACKOFF_MS, + KARTVERKET_MAX_BACKOFF_MS, + KARTVERKET_USER_AGENT, + KARTVERKET_MAX_RESPONSE_BYTES, + KARTVERKET_HEIGHT_RANGE_M, + NYC_CAMERAS_URL, + NYC_IMAGE_URL, + NYC_CAMERA_ID_RE, + DEFAULT_NYC_MAX_SOURCES, + NYC_MAX_CATALOG_BYTES, + NYC_GROUND_ELEVATION_M, + NYC_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -76,19 +133,29 @@ import { fallbackHeadingFromId, isLikelyFinlandCoordinate, fintrafficCameraName, - hashSeed, isPlausibleLatLon, isLikelyBcCoordinate, isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyFloridaCoordinate, + isLikelyCataloniaCoordinate, + decodeNumericEntities, + isLikelyNycCoordinate, + isLikelyHongKongCoordinate, isLikelyNorwayCoordinate, + isLikelyTorontoCoordinate, cameraDisplayCode, rowArrayToObject, prioritizeSources, + pinnedCameraUrl, } from './normalize.js'; import { directionToHeading } from '../../../src/data/directionText.js'; -import { readResponseJsonCapped } from '../common/http.js'; +import { + readResponseBytesCapped, + readResponseJsonCapped, + readResponseTextCapped, +} from '../common/http.js'; /** * Fetch and parse Austin traffic camera records from the city Open Data portal. * @@ -243,9 +310,12 @@ export async function loadCaltransSourcesFromOpenData() { const lon = toFiniteNumber(loc.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - const imageUrl = String(cctv.imageData?.static?.currentImageURL || ''); // Official-host pin (see JSDoc). Also drops records with no still image. - if (!imageUrl.startsWith('https://cwwp2.dot.ca.gov/')) continue; + const imageUrl = pinnedCameraUrl( + cctv.imageData?.static?.currentImageURL, + 'https://cwwp2.dot.ca.gov/', + ); + if (!imageUrl) continue; const locationName = String(loc.locationName || '').trim(); // Leading token of locationName is the stable camera code ("TV102 -- I-580 : …"). @@ -350,8 +420,8 @@ export async function loadTflSourcesFromOpenData() { const lat = toFiniteNumber(place?.lat); const lon = toFiniteNumber(place?.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - const imageUrl = String(props.imageUrl || ''); - if (!imageUrl.startsWith(TFL_IMAGE_ORIGIN)) continue; // official-bucket pin + const imageUrl = pinnedCameraUrl(props.imageUrl, TFL_IMAGE_ORIGIN); + if (!imageUrl) continue; // official-bucket pin // "JamCams_00002.00865" → "tfl-00002.00865" (provider-stable id). const rawId = String(place?.id || '').replace(/^JamCams_/, ''); @@ -463,22 +533,56 @@ function pickOntarioCctvView(views) { ); } +let ontarioKeyMissingLogged = false; + +/** Test seam: let the next keyless call log the missing-key line again. */ +export function _resetOntarioKeyWarningForTest() { + ontarioKeyMissingLogged = false; +} + /** - * Fetch Ontario 511 CCTV cameras. Keyless: the catalog is exposed by the - * public 511 API, while frame URLs are stable still-image endpoints under - * 511on.ca/map/Cctv/. Only rows with finite Ontario coords and at least one - * enabled official still view are kept. + * Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API, + * which now requires a free developer key (511on.ca/developers/doc, "Requires a + * developer key"; unkeyed calls answer 400 "Invalid Key"), read from + * ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line. + * Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only + * rows with finite Ontario coords and at least one enabled official still + * view are kept. * + * @param {object} [_options] The catalog's loader options (unused). + * @param {object} [env] Environment to read the key and the source cap from. * @returns {Promise>} Normalized camera source objects. */ -export async function loadOntarioSourcesFromOpenData() { +export async function loadOntarioSourcesFromOpenData( + _options = {}, + env = process.env, +) { + const key = String(env.ONTARIO_511_API_KEY ?? '').trim(); + if (!key) { + if (!ontarioKeyMissingLogged) { + ontarioKeyMissingLogged = true; + console.warn( + '[CCTV] Ontario 511 cameras skipped: the 511on.ca API needs a free developer key (set ONTARIO_511_API_KEY)', + ); + } + return []; + } try { - const resp = await fetch(ONTARIO_511_CAMERAS_URL, { + const url = new URL(ONTARIO_511_CAMERAS_URL); + url.searchParams.set('key', key); + const resp = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), }); if (!resp.ok) { - console.warn('[CCTV] Ontario 511 camera download failed:', resp.status); + // The API answers 400 "Invalid Key" to a wrong or revoked key. + const keyHint = + resp.status === 400 || resp.status === 401 || resp.status === 403 + ? ' (check ONTARIO_511_API_KEY)' + : ''; + console.warn( + `[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`, + ); return []; } const rows = await resp.json(); @@ -541,7 +645,7 @@ export async function loadOntarioSourcesFromOpenData() { new Map(cameras.map((camera) => [camera.id, camera])).values(), ); const maxRaw = Number( - process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES, + env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES, ); const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(1000, Math.floor(maxRaw))) @@ -1026,13 +1130,15 @@ export function loadTallinnSourcesFromCatalog({ // Rough Estonia/Tallinn metro sanity (allows nearby suburbs already in the pack). if (lat < 59.2 || lat > 59.7 || lon < 24.3 || lon > 25.4) continue; - const imageUrl = + const imageUrl = pinnedCameraUrl( typeof item.url === 'string' - ? item.url.trim() + ? item.url : typeof item.snapshotUrl === 'string' - ? item.snapshotUrl.trim() - : ''; - if (!imageUrl.startsWith(TALLINN_IMAGE_ORIGIN)) continue; + ? item.snapshotUrl + : '', + TALLINN_IMAGE_ORIGIN, + ); + if (!imageUrl) continue; const extractedHeading = toFiniteNumber(item.headingDeg, NaN); const hasHeading = Number.isFinite(extractedHeading); @@ -1134,8 +1240,8 @@ export function parseTarkteeDatexImages(xml) { body, ); if (!refMatch || !urlMatch) continue; - const url = urlMatch[1].trim(); - if (!url.startsWith(TARKTEE_IMAGE_ORIGIN)) continue; + const url = pinnedCameraUrl(urlMatch[1], TARKTEE_IMAGE_ORIGIN); + if (!url) continue; out.set(refMatch[1], url); } return out; @@ -1152,28 +1258,73 @@ export function parseTarkteeDatexImages(xml) { */ export async function loadTarkteeSourcesFromDatex() { try { - const [locResp, imgResp] = await Promise.all([ + const responses = await Promise.allSettled([ fetch(TARKTEE_LOCATIONS_URL, { headers: { Accept: 'application/xml,text/xml,*/*' }, - signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + redirect: 'manual', + signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS), }), fetch(TARKTEE_IMAGES_URL, { headers: { Accept: 'application/xml,text/xml,*/*' }, - signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + redirect: 'manual', + signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS), }), ]); + const [locResp, imgResp] = responses.map((result) => + result.status === 'fulfilled' ? result.value : undefined, + ); + // Both responses are fetched in parallel; if either is rejected or overflows + // its byte cap, both bodies must be released so neither transport leaks. + const discardBoth = async () => { + await Promise.allSettled([ + locResp?.body?.cancel(), + imgResp?.body?.cancel(), + ]); + return []; + }; + const rejected = responses.find((result) => result.status === 'rejected'); + if (rejected) { + await discardBoth(); + throw rejected.reason; + } + if ( + (locResp.status >= 300 && locResp.status < 400) || + (imgResp.status >= 300 && imgResp.status < 400) + ) { + console.warn( + '[CCTV] Tarktee DATEX catalog redirected; redirects are not followed', + ); + return discardBoth(); + } if (!locResp.ok) { console.warn('[CCTV] Tarktee locations download failed:', locResp.status); - return []; + return discardBoth(); } if (!imgResp.ok) { console.warn('[CCTV] Tarktee images download failed:', imgResp.status); - return []; + return discardBoth(); + } + let locXml; + let imgXml; + const readAbort = new AbortController(); + try { + [locXml, imgXml] = await Promise.all([ + readResponseTextCapped( + locResp, + TARKTEE_MAX_CATALOG_BYTES, + readAbort.signal, + ), + readResponseTextCapped( + imgResp, + TARKTEE_MAX_CATALOG_BYTES, + readAbort.signal, + ), + ]); + } catch (error) { + readAbort.abort(); + await discardBoth(); + throw error; } - const [locXml, imgXml] = await Promise.all([ - locResp.text(), - imgResp.text(), - ]); const locations = parseTarkteeDatexLocations(locXml); const images = parseTarkteeDatexImages(imgXml); if (!locations.size || !images.size) { @@ -1185,6 +1336,7 @@ export async function loadTarkteeSourcesFromDatex() { } const cameras = []; + const seen = new Set(); for (const [locationId, loc] of locations.entries()) { const imageUrl = images.get(locationId); if (!imageUrl) continue; @@ -1196,6 +1348,8 @@ export async function loadTarkteeSourcesFromDatex() { const cameraId = numMatch ? `ee-tarktee-${numMatch[1]}` : `ee-tarktee-${locationId}`; + if (seen.has(cameraId)) continue; + seen.add(cameraId); cameras.push({ id: cameraId, name: loc.name, @@ -1273,14 +1427,16 @@ export function loadWarendorfSourcesFromCatalog({ for (const item of rows) { if (!item || typeof item !== 'object') continue; const id = typeof item.id === 'string' ? item.id.trim() : ''; - const url = + const raw = typeof item.url === 'string' - ? item.url.trim() + ? item.url : typeof item.snapshotUrl === 'string' - ? item.snapshotUrl.trim() + ? item.snapshotUrl : ''; - if (!id || !WARENDORF_IMAGE_ORIGINS.some((o) => url.startsWith(o))) - continue; + const url = WARENDORF_IMAGE_ORIGINS.map((o) => + pinnedCameraUrl(raw, o), + ).find(Boolean); + if (!id || !url) continue; const lat = typeof item.lat === 'number' ? item.lat : NaN; const lon = typeof item.lon === 'number' ? item.lon : NaN; if (!isPlausibleLatLon(lat, lon)) continue; @@ -1334,8 +1490,8 @@ export function nswCameraToSource(feature) { const lat = typeof coords?.[1] === 'number' ? coords[1] : NaN; if (!isLikelyNswCoordinate(lat, lon)) return null; const props = feature?.properties || {}; - const url = String(props.href || '').trim(); - if (!url.startsWith(NSW_IMAGE_ORIGIN)) return null; + const url = pinnedCameraUrl(props.href, NSW_IMAGE_ORIGIN); + if (!url) return null; // "N-E" -> "NE" for the compass lookup. const direction = String(props.direction || '') .trim() @@ -1429,8 +1585,7 @@ export function normalizeCalgaryImageUrl(raw) { } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; parsed.protocol = 'https:'; - const upgraded = parsed.toString(); - return upgraded.startsWith(CALGARY_IMAGE_ORIGIN) ? upgraded : null; + return pinnedCameraUrl(parsed.toString(), CALGARY_IMAGE_ORIGIN); } /** @@ -1605,6 +1760,480 @@ export async function loadCalgarySourcesFromOpenData() { } } +/** + * Parse the Catalonia (`cameres.xml`) WFS/GML feed into raw feature records. + * Regex-based rather than a full XML parser — the feed is small (~160 + * features) and this avoids a new dependency for one field set per feature. + * + * @param {string} xml + * @returns {Array<{fid:string, lat:number, lon:number, carretera:string, municipi:string, pk:string, link:string, font:string}>} + */ +export function parseCataloniaXml(xml) { + const out = []; + const blockRe = /([\s\S]*?)<\/gml:featureMember>/g; + const tag = (block, name) => { + const match = new RegExp(`([^<]*)<\\/cite:${name}>`).exec( + block, + ); + return match ? decodeNumericEntities(match[1]) : ''; + }; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const block = match[1]; + const fidMatch = /]*>([^<]+)<\/gml:coordinates>/.exec( + block, + ); + if (!fidMatch || !coordsMatch) continue; + const [lonRaw, latRaw] = coordsMatch[1].split(','); + out.push({ + fid: fidMatch[1], + lon: toFiniteNumber(lonRaw), + lat: toFiniteNumber(latRaw), + carretera: tag(block, 'carretera'), + municipi: tag(block, 'municipi'), + pk: tag(block, 'pk'), + link: tag(block, 'link'), + font: tag(block, 'font'), + }); + } + return out; +} + +/** + * Validate and normalize one Catalonia feed `link` against the publisher + * host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects + * anything else, including a host that merely resembles an allowed one, any + * URL carrying userinfo (`https://user:pass@www.bcn.cat/...`) or an explicit + * port — the hostname check alone would pass either, letting catalog data + * inject credentials or steer the server-side frame request to another + * service on that host — and any path outside the host's frame directory. + * + * SCT's own `RenderService` frame link is a special case, for two stacked + * reasons (both verified 2026-09-15): + * 1. It always 302s to `TransitCamera`, on a Location the upstream + * hardcodes as plain http even when RenderService itself was requested + * over https. fetchWithinHost (media.js) only follows same-origin + * redirects — same scheme included, by design, to stop an upstream + * steering a host-pinned request into a plaintext downgrade — so that + * hop is refused and the camera would render as a placeholder. + * 2. mct.gencat.cat's TLS setup itself does not complete a handshake with + * Node's fetch/OpenSSL (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though it + * is reachable over https from other clients (e.g. curl) — an upstream + * server misconfiguration this proxy cannot route around. + * TransitCamera answers the frame directly over **http**, no redirect, and + * that is the only transport this host actually serves to this runtime — so + * this builds that URL itself from RenderService's `sctidcam` id instead of + * registering the (https, then-redirecting) RenderService link. The + * `sctidcam` id must be a bare image filename (CATALONIA_SCT_CAMERA_FILE_RE). + * + * @param {string} rawUrl + * @returns {string} Normalized URL, or '' if not accepted. + */ +export function normalizeCataloniaImageUrl(rawUrl) { + let parsed; + try { + parsed = new URL(String(rawUrl || '').trim()); + } catch { + return ''; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + if (parsed.username || parsed.password || parsed.port) return ''; + const dir = CATALONIA_IMAGE_HOSTS.get(parsed.hostname); + if (!dir || !parsed.pathname.startsWith(dir)) return ''; + const file = parsed.pathname.slice(dir.length); + if (!file || file.includes('/')) return ''; + + if (parsed.hostname === 'mct.gencat.cat') { + const camId = parsed.searchParams.get('sctidcam'); + if (file !== 'RenderService') return ''; + if (!camId || !CATALONIA_SCT_CAMERA_FILE_RE.test(camId)) return ''; + const direct = new URL('http://mct.gencat.cat/mct2bo/TransitCamera'); + direct.searchParams.set('nom', camId); + direct.searchParams.set('visualitzacio', 'imatge'); + return direct.toString(); + } + + parsed.protocol = 'https:'; + return parsed.toString(); +} + +/** + * Stable camera id for one normalized Catalonia frame URL (the output of + * normalizeCataloniaImageUrl). The feed's `fid` is not used: it embeds an + * upstream server-start stamp and changes on every upstream restart. SCT + * cameras key on the `sctidcam` filename stem (`cat-sct-nc87`), partner + * cameras on the frame host and path (`cat-www-bcn-cat-transit-imatges-…`). + * + * @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted. + * @returns {string} + */ +function cataloniaCameraId(imageUrl) { + const parsed = new URL(imageUrl); + const slug = (text) => + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (parsed.hostname === 'mct.gencat.cat') { + const file = parsed.searchParams.get('nom') || ''; + return `cat-sct-${slug(file.replace(/\.[^.]*$/, ''))}`; + } + return `cat-${slug(parsed.hostname)}-${slug(parsed.pathname)}`; +} + +/** + * Human label for one Catalonia camera: " ()", + * skipping the parenthetical when the municipality is already named in the + * road/place text or when one half is missing. + * + * @param {string} carretera + * @param {string} municipi + * @returns {string} + */ +export function cataloniaCameraName(carretera, municipi) { + const road = String(carretera || '').trim(); + const city = String(municipi || '').trim(); + if (road && city) { + return road.toLowerCase().includes(city.toLowerCase()) + ? road + : `${road} (${city})`; + } + return road || city || 'Càmera de trànsit'; +} + +/** + * Fetch Catalonia traffic cameras (Servei Català de Trànsit), keyless: one + * WFS/GML XML list. The feed carries a `font` field naming the real + * publisher per camera; only SCT's own highway cameras and the hotlinked + * Barcelona (IMI) and Terrassa municipal cameras are kept — the publishers + * whose frame links were confirmed to serve images. Frame URLs are checked + * against a host allowlist (CATALONIA_IMAGE_HOSTS), so a row from any other + * publisher is dropped. The feed carries no camera status, so a camera its + * publisher has taken offline still registers and serves that publisher's + * own "image unavailable" still. + * Each non-SCT camera carries its publisher as a `credit` alongside the + * shared `provider`. The feed carries no heading, so every camera takes the + * id-hash fallback, low-confidence pose (same as TfL and Fintraffic). + * + * Ids come from the validated frame URL (cataloniaCameraId), not the feed's + * restart-dependent `fid`, and rows sharing one are collapsed before the cap + * (the live feed repeats a few SCT cameras). + * + * The list decides ids, coordinates, labels and which allowlisted frame path + * is proxied, so it is fetched over https, redirects are refused (the list + * host cannot be steered or downgraded) and the body read is capped at + * CATALONIA_MAX_CATALOG_BYTES. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadCataloniaSourcesFromOpenData() { + try { + const resp = await fetch(CATALONIA_CAMERAS_URL, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + // A response this loader will not read still owns its transport until the + // body is released, so every rejection path cancels before returning. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Catalonia catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Catalonia camera download failed:', resp.status); + return discard(); + } + const xml = await readResponseTextCapped(resp, CATALONIA_MAX_CATALOG_BYTES); + const rows = parseCataloniaXml(xml); + if (!rows.length) return []; + + const cameras = []; + for (const row of rows) { + if (!isLikelyCataloniaCoordinate(row.lat, row.lon)) continue; + const imageUrl = normalizeCataloniaImageUrl(row.link); + if (!imageUrl) continue; + + const font = row.font.trim(); + const cameraId = cataloniaCameraId(imageUrl); + + cameras.push({ + id: cameraId, + name: cataloniaCameraName(row.carretera, row.municipi), + city: row.municipi.trim() || 'Catalunya', + cityId: 'catalunya', + provider: 'Servei Català de Trànsit', + lat: row.lat, + lon: row.lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: CATALONIA_FONT_ELEVATION_M[font] ?? 80, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'catalonia-open-data', + license: + "Generalitat de Catalunya — Llicència oberta d'ús d'informació", + credit: CATALONIA_FONT_CREDIT[font] || '', + }); + } + + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_CATALONIA_MAX_SOURCES || DEFAULT_CATALONIA_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_CATALONIA_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, CATALONIA_ANCHORS); + console.log( + `[CCTV] Loaded Catalonia camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Catalonia camera download error:', + error?.message || error, + ); + return []; + } +} + +/** + * Read one XML child tag's text from an `` block. + * + * @param {string} body + * @param {string} tag + * @returns {string} + */ +function hkXmlTag(body, tag) { + const match = new RegExp(`<${tag}>\\s*([^<]*?)\\s*`, 'i').exec( + String(body || ''), + ); + return match ? match[1].trim() : ''; +} + +/** + * Parse the Transport Department English camera-locations XML into rows. + * + * Schema-specific: each `` carries key, description, lat/lon and a + * still URL. No XML dependency — same regex style as the Tarktee DATEX parser. + * + * @param {string} xml + * @returns {Array} + */ +export function parseHkCameraLocationsXml(xml) { + const rows = []; + const blockRe = /([\s\S]*?)<\/image>/gi; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const body = match[1]; + rows.push({ + key: hkXmlTag(body, 'key'), + region: hkXmlTag(body, 'region'), + district: hkXmlTag(body, 'district'), + description: hkXmlTag(body, 'description'), + latitude: hkXmlTag(body, 'latitude'), + longitude: hkXmlTag(body, 'longitude'), + url: hkXmlTag(body, 'url'), + }); + } + return rows; +} + +/** + * Build a host-pinned still URL from a Transport Department camera key. + * + * The catalog also publishes a URL; the registered frame is always rebuilt + * from the key so a catalog edit cannot steer the proxy off-origin. + * + * @param {string|null|undefined} key + * @returns {?string} + */ +export function hkImageUrlFromKey(key) { + const id = String(key ?? '') + .trim() + .toUpperCase(); + if (!/^[A-Z0-9]{1,32}$/.test(id)) return null; + return `${HK_IMAGE_ORIGIN}${id}.JPG`; +} + +/** + * Pin a catalog frame URL to the Transport Department still host. + * + * @param {string|null|undefined} raw + * @returns {?string} + */ +export function normalizeHkImageUrl(raw) { + const text = String(raw ?? '').trim(); + if (!text) return null; + let parsed; + try { + parsed = new URL(text); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + parsed.protocol = 'https:'; + return pinnedCameraUrl(parsed.toString(), HK_IMAGE_ORIGIN); +} + +/** + * Label for one HK camera: the published English description, else the key. + * + * @param {object} record + * @param {string} cameraId + * @returns {string} + */ +export function hkCameraName(record, cameraId) { + const description = String(record?.description ?? '').trim(); + if (description) return description; + const key = String(record?.key ?? '').trim(); + if (key) return key; + return `HK Camera ${String(cameraId).replace(/^hk-/, '')}`; +} + +/** + * One Transport Department locations row -> one catalog source, or null. + * + * The dataset publishes no camera facing, so headings use the shared id-hash + * fallback at low confidence and are corrected with the calibration gizmo. + * + * @param {object} record + * @returns {?object} + */ +export function hkCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + const key = String(record.key ?? '') + .trim() + .toUpperCase(); + if (!key) return null; + const lat = toFiniteNumber(record.latitude); + const lon = toFiniteNumber(record.longitude); + if (!isLikelyHongKongCoordinate(lat, lon)) return null; + + const imageUrl = hkImageUrlFromKey(key); + if (!imageUrl) return null; + // If the catalog carries a URL, it must already be on the pinned host. + const catalogUrl = String(record.url ?? '').trim(); + if (catalogUrl) { + const pinnedCatalog = normalizeHkImageUrl(catalogUrl); + if (!pinnedCatalog) return null; + } + + const cameraId = `hk-${key.toLowerCase()}`; + const name = hkCameraName(record, cameraId); + const region = String(record.region ?? '').trim(); + + return { + id: cameraId, + name, + city: region || 'Hong Kong', + cityId: 'hk', + provider: 'Transport Department', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + // Coastal / harbour prior; the client's one-shot ground snap corrects. + groundElevationM: 15, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'hk-td-open-data', + license: + 'Hong Kong Transport Department / DATA.GOV.HK — attribution required', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch Hong Kong Transport Department traffic snapshot cameras from the + * keyless DATA.GOV.HK English locations XML. Frames are stills on + * tdcctv.data.one.gov.hk. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadHkSourcesFromOpenData() { + try { + const endpoint = process.env.CCTV_HK_CAMERAS_URL || DEFAULT_HK_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] HK catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] HK camera download failed:', resp.status); + return discard(); + } + let xml; + try { + xml = await readResponseTextCapped(resp, HK_MAX_CATALOG_BYTES); + } catch (error) { + if (error?.code === 'RESPONSE_TOO_LARGE') { + console.warn('[CCTV] HK catalog body exceeded byte ceiling'); + return []; + } + throw error; + } + const rows = parseHkCameraLocationsXml(xml); + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = hkCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_HK_MAX_SOURCES || DEFAULT_HK_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_HK_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, HK_ANCHORS); + console.log( + `[CCTV] Loaded HK camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] HK camera download error:', error?.message || error); + return []; + } +} + export async function loadDelDOTSourcesFromOpenData() { try { const resp = await fetch(DELDOT_CCTV_URL, { @@ -1709,6 +2338,325 @@ export async function loadDelDOTSourcesFromOpenData() { } } +/** Rough King County, WA bounding box. */ +function isLikelyKingCountyCoordinate(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + lat >= 47.0 && + lat <= 48.0 && + lon >= -123.1 && + lon <= -121.0 + ); +} + +/** + * A King County `Model` value, whitespace-collapsed, or '' when it is a + * vendor-generic string ("PTZ", "PTZ IP", "BOSCH/PTZ") that names no model. + */ +function kingCountyModel(raw) { + const model = String(raw || '') + .replace(/\s+/g, ' ') + .trim(); + return /^(?:[a-z]+\/)?ptz(?: ip)?$/i.test(model) ? '' : model; +} + +/** + * Fetch King County road cameras (Washington State). Keyless: one public + * ArcGIS FeatureServer GeoJSON query (King County DOT Road Services), with an + * explicit `outFields` list so the layer's asset-administration columns are + * never fetched. Only cameras that are live and active (`Live`, + * `CurrentStatus`) with a positive integer id, finite in-county coordinates, + * and a frame on the county's own image handler are kept. WSDOT-owned rows in + * the layer are skipped because their frames are WSDOT-hosted, outside the + * pinned image origin, while partner-city cameras hosted on the county image + * handler (e.g. Newcastle) name their owner as the per-camera credit, + * DriveBC-style. The layer publishes no facing, so every heading is the id + * hash at low confidence. + * + * Uniquely among the packs, the county publishes each camera's hardware + * `Manufacturer`/`Model`; the model string (whitespace-collapsed — the feed + * carries stray newlines) rides along as the source's `model` for + * identified-hardware consumers. Vendor-generic strings ("PTZ", "PTZ IP", + * "BOSCH/PTZ") name no model and are treated as absent. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadKingCountySourcesFromOpenData() { + try { + const resp = await fetch(KING_COUNTY_CAMERAS_URL, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + // A response this loader will not read still owns its transport until the + // body is released, so every rejection path cancels before returning. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] King County catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] King County camera download failed:', resp.status); + return discard(); + } + const payload = await readResponseJsonCapped( + resp, + KING_COUNTY_MAX_CATALOG_BYTES, + ); + const rows = Array.isArray(payload?.features) ? payload.features : []; + + const cameras = []; + const seen = new Set(); + for (const row of rows) { + const attrs = row?.properties; + const rawId = attrs?.AssetID; + if (!Number.isSafeInteger(rawId) || rawId <= 0) continue; + if (attrs.Live !== 1) continue; + if (String(attrs.CurrentStatus || '').trim() !== 'Active') continue; + const owner = String(attrs.Owner || '').trim(); + // WSDOT-owned rows serve WSDOT-hosted frames, outside the pinned + // county image origin, so they are skipped. + if (owner === 'WSDOT') continue; + // The handler is served over HTTPS; the feed still writes http://. + const imageUrl = pinnedCameraUrl( + String(attrs.ImageURL || '') + .trim() + .replace( + /^http:\/\/info\.kingcounty\.gov\//, + KING_COUNTY_IMAGE_ORIGIN, + ), + KING_COUNTY_IMAGE_ORIGIN, + ); + if (!imageUrl) continue; + const [lon, lat] = Array.isArray(row?.geometry?.coordinates) + ? row.geometry.coordinates + : []; + if (!isLikelyKingCountyCoordinate(lat, lon)) continue; + + const cameraId = `kingcounty-${rawId}`; + if (seen.has(cameraId)) continue; + seen.add(cameraId); + const model = kingCountyModel(attrs.Model); + const manufacturer = String(attrs.Manufacturer || '') + .replace(/\s+/g, ' ') + .trim(); + const camera = { + id: cameraId, + name: + String(attrs.Location || '').trim() || `King County camera ${rawId}`, + city: 'King County', + cityId: 'king-county', + provider: 'King County Road Services', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + // The low-confidence pose personality shared by the other packs. + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'kingcounty-open-data', + license: 'King County GIS open data (KCGIS Center terms of use)', + credit: owner && owner !== 'King County' ? owner : '', + }; + if (model) camera.model = model; + if (model && manufacturer) camera.manufacturer = manufacturer; + cameras.push(camera); + } + + const maxRaw = Number( + process.env.CCTV_KINGCOUNTY_MAX_SOURCES || + DEFAULT_KING_COUNTY_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_KING_COUNTY_MAX_SOURCES; + const prioritized = prioritizeSources( + cameras, + maxCount, + KING_COUNTY_ANCHORS, + ); + console.log( + `[CCTV] Loaded King County camera sources: ${cameras.length} published (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] King County camera download error:', + error?.message || error, + ); + return []; + } +} + +/** WSDOT `CompassDirection` codes as headings in degrees. The live feed sends + * single cardinals (N/E/S/W), `B` (both directions — most rows), `O`, and + * null; the diagonals are mapped in case they appear. `B`, `O`, null, and any + * other code carry no single facing and fall through to the id hash. */ +const WSDOT_COMPASS_HEADINGS = Object.freeze({ + N: 0, + NE: 45, + E: 90, + SE: 135, + S: 180, + SW: 225, + W: 270, + NW: 315, +}); + +/** + * Converts an EPSG:3857 (Web Mercator) coordinate pair to WGS84 degrees. + * The WSDOT camera layer publishes point geometry in wkid 102100. + * @param {number} x - Web Mercator metres east. + * @param {number} y - Web Mercator metres north. + * @returns {{lat: number, lon: number}} WGS84 degrees. + */ +export function webMercatorToWgs84(x, y) { + const R = 6378137; + const lon = (x / R) * (180 / Math.PI); + const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI); + return { lat, lon }; +} + +/** Rough Washington State bounding box (with the Columbia River border cams). */ +function isLikelyWashingtonCoordinate(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + lat >= 45.3 && + lat <= 49.3 && + lon >= -125 && + lon <= -116.5 + ); +} + +/** + * Fetch WSDOT highway cameras (Washington State). Keyless: one Esri JSON + * layer on WSDOT's official open-data host (the same layer the WSDOT Travel + * Center map consumes; a keyed Traveler Information API serves the identical + * population for higher-volume use). Only cameras with a positive integer id, + * finite in-state coordinates, and a frame on WSDOT's own image host are + * kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle, + * lodges, airports) are skipped rather than proxied under someone else's + * terms. A single-facing `CompassDirection` (a cardinal) gives a + * high-confidence heading; `B` (both directions), `O`, and null fall back to + * the id hash. + * The feed carries Windows-1252 bytes (en-dashes in titles) with no charset + * declaration, so the body is decoded explicitly before parsing. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadWsdotSourcesFromOpenData() { + try { + const resp = await fetch(WSDOT_CAMERAS_URL, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] WSDOT catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] WSDOT camera download failed:', resp.status); + return discard(); + } + const payload = JSON.parse( + new TextDecoder('windows-1252').decode( + await readResponseBytesCapped(resp, WSDOT_MAX_CATALOG_BYTES), + ), + ); + const rows = Array.isArray(payload?.features) ? payload.features : []; + + const cameras = []; + for (const row of rows) { + const attrs = row?.attributes; + const rawId = attrs?.CameraID; + if (!Number.isSafeInteger(rawId) || rawId <= 0) continue; + const imageUrl = pinnedCameraUrl(attrs.ImageURL, WSDOT_IMAGE_ORIGIN); + if (!imageUrl) continue; + const x = row?.geometry?.x; + const y = row?.geometry?.y; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + const { lat, lon } = webMercatorToWgs84(x, y); + if (!isLikelyWashingtonCoordinate(lat, lon)) continue; + + const cameraId = `wsdot-${rawId}`; + const heading = + WSDOT_COMPASS_HEADINGS[ + String(attrs.CompassDirection || '') + .trim() + .toUpperCase() + ]; + const hasHeading = Number.isFinite(heading); + cameras.push({ + id: cameraId, + name: String(attrs.CameraTitle || '').trim() || `WSDOT camera ${rawId}`, + city: 'Washington', + cityId: 'washington-state', + provider: 'WSDOT', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + // Same two pose personalities as the other packs: raw priors that the + // client's ground snap and manual calibration refine. + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'wsdot-open-data', + license: 'WSDOT Travel Center camera data (public low-volume feed)', + }); + } + + const maxRaw = Number( + process.env.CCTV_WSDOT_MAX_SOURCES || DEFAULT_WSDOT_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_WSDOT_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, WSDOT_ANCHORS); + console.log( + `[CCTV] Loaded WSDOT camera sources: ${cameras.length} published (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] WSDOT camera download error:', + error?.message || error, + ); + return []; + } +} + /** * One Statens vegvesen `CctvSimple` feature -> one catalog source, or null. * @@ -1779,14 +2727,11 @@ export function vegvesenCameraToSource(feature) { fovDeg: 44, rangeM: 145, mountHeightM: 8, - // KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat - // prior for the whole country, while cameras run from sea level to - // mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen). - // The point-height prior and the client's one-shot ground snap correct it - // where they resolve; on a stack where neither does (no 3D tiles), a pass - // camera stays hundreds of metres below the terrain, the same risk the - // Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless - // point-height API) would remove it. + // The feed's coordinates are 2D. The loader replaces this flat prior with + // Kartverket's terrain height for each camera (applyVegvesenGroundHeights); + // it only survives when that lookup fails, and then a mountain-pass camera + // can sit below the terrain on a stack without 3D tiles, the same risk the + // Caltrans pack documents. groundElevationM: 150, feedType: videoUrl ? 'hls' : 'image', url: videoUrl || imageUrl, @@ -1806,6 +2751,7 @@ export function vegvesenCameraToSource(feature) { * @returns {Promise>} Normalized camera source objects. */ export async function loadVegvesenSourcesFromOpenData() { + const startedAt = Date.now(); try { const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL; const resp = await fetch(endpoint, { @@ -1851,6 +2797,12 @@ export async function loadVegvesenSourcesFromOpenData() { ? Math.max(8, Math.min(1000, Math.floor(maxRaw))) : DEFAULT_VEGVESEN_MAX_SOURCES; const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS); + await applyVegvesenGroundHeights(prioritized, { + waitMs: Math.max( + 0, + KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt), + ), + }); console.log( `[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`, ); @@ -1863,3 +2815,833 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +/** + * Kartverket answers by rounded position: `{z, expires}`, where z is metres or + * null for "no height there". Heights never expire (camera positions barely + * change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog + * refresh therefore only asks about new or moved cameras. Bounded well above + * the ~900 cameras the pack can load. + */ +const kartverketHeightCache = new Map(); +const KARTVERKET_CACHE_LIMIT = 5000; +/** The lookup in flight, so overlapping catalog loads never start a second. */ +let kartverketPending = null; +/** No lookup starts before this time (epoch ms) after a transport failure. */ +let kartverketBackoffUntil = 0; +/** Bumped by clearKartverketHeightCache, so a lookup started before a clear + * can no longer write to the cache, the backoff or the pending slot. */ +let kartverketGeneration = 0; + +/** Cache key for one position, at the ~0.1 m precision the request sends. */ +const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`; + +/** Statuses that say the service, not the request, is failing. */ +const isKartverketTransportStatus = (status) => + status === 408 || status === 429 || status >= 500; + +/** Forget cached heights, the backoff and any lookup in flight. Tests only. */ +export function clearKartverketHeightCache() { + kartverketGeneration += 1; + kartverketHeightCache.clear(); + kartverketPending = null; + kartverketBackoffUntil = 0; +} + +/** + * How long to back off after a failed request: the Retry-After the service + * sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h]. + * + * @param {?string} retryAfter - The Retry-After header, if any. + * @param {number} now - Epoch ms. + * @returns {number} Milliseconds. + */ +export function kartverketBackoffMs(retryAfter, now = Date.now()) { + let ms = KARTVERKET_FAILURE_BACKOFF_MS; + const text = String(retryAfter ?? '').trim(); + if (/^\d+$/.test(text)) ms = Number(text) * 1000; + else if (text && Number.isFinite(Date.parse(text))) { + ms = Date.parse(text) - now; + } + return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms)); +} + +/** + * Terrain heights above sea level (NN2000) from Kartverket's keyless + * point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is + * matched back by the coordinates the API echoes, and only for points that + * request asked about, then reported through `into.set(key, z)` as its + * request lands, so a caller that stops waiting keeps what has arrived. `z` is + * metres, or null when the API has no usable height there. A point a 200 + * answer leaves out is reported as null too, so it is not asked again on + * every refresh. + * + * Over water `/punkt` returns the depth of the sea floor (`datakilde: + * dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either + * marker counts as 0 m, the water surface. For the ~14 bridge cameras that is + * below the deck, but far closer than the sea floor or a flat prior. + * + * The first transport failure (a 408, 429 or 5xx, a network error, a request + * past KARTVERKET_REQUEST_TIMEOUT_MS, an unreadable or oversized body, or the + * shared deadline) stops the lookup: no further requests start, and the + * failure is returned so the caller can back off. Points in failed requests + * are not reported, so they are asked again later. Any other 4xx rejects that + * request's points, not the service: they are reported as null and the lookup + * carries on. + * + * @param {Array<{lon:number, lat:number}>} points + * @param {{into?: {set: Function}, timeoutMs?: number, requestTimeoutMs?: number}} [options] + * @returns {Promise<{heights: Map, failure: ?{status: ?number, retryAfter: ?string, deadline: boolean}}>} + * `heights` is `into` when it is a Map, else a Map of what was reported. + * `failure.deadline` is true when only the shared deadline ran out. + */ +export async function fetchKartverketHeights( + points, + { + into = new Map(), + timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS, + requestTimeoutMs = KARTVERKET_REQUEST_TIMEOUT_MS, + } = {}, +) { + const heights = into instanceof Map ? into : new Map(); + const report = (key, z) => { + if (heights !== into) heights.set(key, z); + into.set(key, z); + }; + const unique = Array.from( + new Map( + (points || []) + .filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon)) + .map((p) => [kartverketKey(p.lon, p.lat), p]), + ).values(), + ); + let failure = null; + if (!unique.length) return { heights, failure }; + const batches = []; + for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) { + batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS)); + } + const deadline = AbortSignal.timeout(timeoutMs); + const fetchBatch = async (batch) => { + const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat))); + const url = new URL(KARTVERKET_HEIGHT_URL); + url.searchParams.set( + 'punkter', + JSON.stringify( + batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]), + ), + ); + url.searchParams.set('koordsys', '4258'); + const resp = await fetch(url, { + headers: { + Accept: 'application/json', + 'User-Agent': KARTVERKET_USER_AGENT, + }, + redirect: 'error', + signal: AbortSignal.any([ + deadline, + AbortSignal.timeout(requestTimeoutMs), + ]), + }); + if (!resp.ok) { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + if (!isKartverketTransportStatus(resp.status)) { + console.warn( + `[CCTV] Kartverket rejected ${asked.size} points: HTTP ${resp.status}`, + ); + for (const key of asked) report(key, null); + return; + } + const error = new Error(`HTTP ${resp.status}`); + error.status = resp.status; + error.retryAfter = resp.headers.get('retry-after'); + throw error; + } + const payload = await readResponseJsonCapped( + resp, + KARTVERKET_MAX_RESPONSE_BYTES, + ); + for (const point of Array.isArray(payload?.punkter) + ? payload.punkter + : []) { + const x = toFiniteNumber(point?.x); + const y = toFiniteNumber(point?.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + const key = kartverketKey(x, y); + // Only the points this request asked about: an odd answer can neither + // grow the cache nor overwrite another camera. + if (!asked.has(key)) continue; + asked.delete(key); + // `z: null` means no height there; Number(null) would read 0. + const raw = point?.z; + let z = + raw === null || raw === undefined || raw === '' + ? NaN + : toFiniteNumber(raw); + if ( + Number.isFinite(z) && + (point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate') + ) { + z = 0; + } + const usable = + Number.isFinite(z) && + z >= KARTVERKET_HEIGHT_RANGE_M.min && + z <= KARTVERKET_HEIGHT_RANGE_M.max; + report(key, usable ? z : null); + } + // A 200 that leaves a point out has no height for it either. + for (const key of asked) report(key, null); + }; + let next = 0; + const worker = async () => { + while (next < batches.length && !failure) { + const batch = batches[next++]; + try { + await fetchBatch(batch); + } catch (error) { + if (!failure) { + failure = { + status: Number.isFinite(error?.status) ? error.status : null, + retryAfter: error?.retryAfter ?? null, + deadline: deadline.aborted, + }; + console.warn( + '[CCTV] Kartverket height lookup failed:', + error?.message || error, + ); + } + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(KARTVERKET_CONCURRENCY, batches.length) }, + worker, + ), + ); + return { heights, failure }; +} + +/** + * Give each Vegvesen camera its own terrain height as the ground prior, in + * place. Waits at most `waitMs` for positions it has no answer for, and only + * when more than KARTVERKET_WAIT_MISSING_SHARE of the cameras are missing + * (a cold cache); a few new cameras or expired misses are looked up in the + * background. A lookup still running when the wait ends keeps filling the + * cache for the next catalog refresh. Cameras without a height keep the flat + * prior. After a transport failure no lookup starts until the backoff ends, + * which outlasts the 15-minute catalog refresh, so a failing Kartverket costs + * one attempt per backoff, not one per refresh. A lookup cut short only by its + * shared deadline is resumed on the next refresh without a backoff. Cameras + * without finite coordinates are skipped. Never throws. + * CCTV_VEGVESEN_HEIGHTS=0 turns it off. + * + * @param {Array} cameras - Sources from vegvesenCameraToSource(). + * @param {{waitMs?: number, now?: () => number, timeoutMs?: number}} [options] + * `timeoutMs` is the lookup's shared deadline. + * @returns {Promise} How many cameras got a terrain height. + */ +export async function applyVegvesenGroundHeights( + cameras, + { + waitMs = KARTVERKET_CATALOG_WAIT_MS, + now = Date.now, + timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS, + } = {}, +) { + if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0; + const list = (Array.isArray(cameras) ? cameras : []).filter( + (c) => Number.isFinite(c?.lon) && Number.isFinite(c?.lat), + ); + const startedAt = now(); + const missing = list.filter((c) => { + const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat)); + return !entry || entry.expires <= startedAt; + }); + if ( + missing.length && + !kartverketPending && + startedAt >= kartverketBackoffUntil + ) { + if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) { + kartverketHeightCache.clear(); + } + const generation = kartverketGeneration; + const current = () => generation === kartverketGeneration; + const sink = { + set: (key, z) => { + if (!current()) return; + kartverketHeightCache.set(key, { + z, + expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity, + }); + }, + }; + const pending = fetchKartverketHeights(missing, { into: sink, timeoutMs }) + .then(({ failure }) => { + if (failure && !failure.deadline && current()) { + kartverketBackoffUntil = + now() + kartverketBackoffMs(failure.retryAfter, now()); + } + }) + .catch((error) => { + if (current()) { + kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS; + } + console.warn( + '[CCTV] Kartverket height lookup error:', + error?.message || error, + ); + }) + .finally(() => { + if (kartverketPending === pending) kartverketPending = null; + }); + kartverketPending = pending; + } + const cold = missing.length > list.length * KARTVERKET_WAIT_MISSING_SHARE; + if (cold && kartverketPending && waitMs > 0) { + let timer; + await Promise.race([ + kartverketPending, + new Promise((resolve) => { + timer = setTimeout(resolve, waitMs); + timer.unref?.(); + }), + ]); + clearTimeout(timer); + } + let applied = 0; + for (const camera of list) { + const z = kartverketHeightCache.get( + kartverketKey(camera.lon, camera.lat), + )?.z; + if (!Number.isFinite(z)) continue; + camera.groundElevationM = Math.round(z * 10) / 10; + applied += 1; + } + if (list.length) { + console.log( + `[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`, + ); + } + return applied; +} + +const NYC_BOROUGHS = new Set([ + 'Manhattan', + 'Brooklyn', + 'Queens', + 'Bronx', + 'Staten Island', +]); + +/** NYC TMC publishes `isOnline` as the strings "true"/"false". */ +export function nycCameraIsOnline(value) { + return ( + value === true || + String(value ?? '') + .trim() + .toLowerCase() === 'true' + ); +} + +/** + * Label for one NYC camera: the intersection an operator recognises + * ("Central Park West @ 86 St"). The catalog's `area` is the borough, not + * a facing, and is not part of the name. + * + * @param {object} record - Raw TMC camera row. + * @param {string} cameraId - Derived stable id. + * @returns {string} + */ +export function nycCameraName(record, cameraId) { + const name = String(record?.name ?? '').trim(); + if (name) return name; + const short = String(cameraId || '') + .replace(/^nyc-/, '') + .slice(0, 8); + return short ? `NYC Camera ${short}` : 'NYC Camera'; +} + +/** + * One NYC DOT TMC camera row -> one catalog source, or null. + * + * Frame URLs are synthesized from a validated UUID on the pinned TMC image + * origin. The payload's `imageUrl` is ignored so a catalog edit cannot steer + * the frame proxy off-host. There is no heading in the feed; headings use + * the shared id-hash fallback at low confidence, like Calgary and Fintraffic. + * + * @param {object} record - Raw TMC camera row. + * @returns {?object} + */ +export function nycCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + if (!nycCameraIsOnline(record.isOnline)) return null; + + const lat = toFiniteNumber(record.latitude); + const lon = toFiniteNumber(record.longitude); + if (!isLikelyNycCoordinate(lat, lon)) return null; + + const uuid = String(record.id ?? '').trim(); + if (!NYC_CAMERA_ID_RE.test(uuid)) return null; + const cameraId = `nyc-${uuid.toLowerCase()}`; + const imageUrl = NYC_IMAGE_URL(uuid); + const name = nycCameraName(record, cameraId); + const area = String(record.area ?? '').trim(); + const city = NYC_BOROUGHS.has(area) ? area : 'New York'; + + return { + id: cameraId, + name, + city, + cityId: 'nyc', + provider: 'NYC DOT Traffic Management Center', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: NYC_GROUND_ELEVATION_M, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'nyc-dot-tmc', + license: 'Public NYC DOT Traffic Management Center traffic camera', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch NYC DOT Traffic Management Center cameras from the keyless + * `webcams.nyctmc.org` catalog. Frames are stills on that same host, + * registered from a validated UUID rather than from the payload URL. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadNycSourcesFromOpenData() { + try { + const endpoint = process.env.CCTV_NYC_CAMERAS_URL || NYC_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] NYC catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] NYC camera download failed:', resp.status); + return discard(); + } + const rows = await readResponseJsonCapped(resp, NYC_MAX_CATALOG_BYTES); + if (!Array.isArray(rows)) return []; + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = nycCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_NYC_MAX_SOURCES || DEFAULT_NYC_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(400, Math.floor(maxRaw))) + : DEFAULT_NYC_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, NYC_ANCHORS); + console.log( + `[CCTV] Loaded NYC camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] NYC camera download error:', error?.message || error); + return []; + } +} + +/** + * The JSONP envelope the City of Toronto wraps its camera list in. Anchored at + * both ends, so only that exact callback is unwrapped — a payload that merely + * contains the name somewhere is not treated as the list. + */ +const RESCU_JSONP = /^jsonTMCEarthCamerasCallback\(([\s\S]*)\)\s*;?$/; + +/** + * Unwrap the RESCU camera list. The portal serves the list as JSONP; should it + * ever drop the wrapper, the bare JSON document still parses. Anything else — + * a foreign callback, a truncated body, an error page — yields no cameras + * rather than a thrown loader. + * + * @param {string} text - Raw response body. + * @returns {Array} The `Data` rows, or [] when the body is unusable. + */ +export function parseRescuCameraList(text) { + const body = String(text ?? '').trim(); + if (!body) return []; + const unwrapped = RESCU_JSONP.exec(body)?.[1] ?? body; + try { + const parsed = JSON.parse(unwrapped); + const rows = parsed?.Data ?? parsed?.data; + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +/** + * Convert one RESCU list row into a camera source. + * + * The row carries no URL: the frame URL is built from the documented + * `loc####.jpg` convention and a camera number validated as plain digits, so + * the frame proxy is pinned to the city origin by construction and no upstream + * field — present or planted — can steer it. + * + * No compass heading exists in this dataset. `D1`-`D4` are the directions of + * the *comparison* images (static reference stills captured in 2016, served + * from a different path); the live frame is one pan-tilt-zoom camera pointing + * wherever the operator left it. Treating those letters as a facing would put + * a confident, wrong bearing on every camera in the city, so every row takes + * the id-hash fallback and the low-confidence pose, as headingless Austin, + * TfL, Fintraffic and Calgary cameras do. + * + * @param {object} row - One entry from the RESCU `Data` array. + * @returns {object|null} Normalized camera source, or null when unusable. + */ +export function torontoCameraToSource(row) { + if (!row || typeof row !== 'object') return null; + const number = String(row.Number ?? row.number ?? '').trim(); + // Plain digits only: a number is about to become a URL path segment. + if (!/^\d{1,6}$/.test(number)) return null; + + const lat = toFiniteNumber(row.Latitude ?? row.latitude); + const lon = toFiniteNumber(row.Longitude ?? row.longitude); + if (!isLikelyTorontoCoordinate(lat, lon)) return null; + + const cameraId = `toronto-${number}`; + // The feed's own label, verbatim. It is upper-case ("MCCOWAN RD", "ST + // HELEN'S AVE"); title-casing it would mangle more names than it fixed. + const name = + String(row.Name ?? row.name ?? '') + .replace(/\s+/g, ' ') + .trim() || `Toronto Camera ${number}`; + + return { + id: cameraId, + name, + city: 'Toronto', + cityId: 'toronto', + provider: 'City of Toronto', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: TORONTO_GROUND_ELEVATION_M, + feedType: 'image', + url: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`, + snapshotUrl: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`, + sourceKind: 'toronto-rescu-open-data', + license: + 'Contains information licensed under the Open Government Licence – Toronto', + // Unselected-label code: the intersection, so a camera at rest reads as a + // place rather than as its id. + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch City of Toronto RESCU traffic cameras from the City's open data + * portal, keyless. One JSONP list for the whole city; frames are stills on the + * same portal origin. + * + * The list fetch refuses redirects (`redirect: 'manual'`) so the list host + * cannot be steered, and caps the body it will buffer. + * + * Attribution: Open Government Licence – Toronto, registered in + * src/data/dataCredits.js. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadTorontoSourcesFromOpenData() { + try { + const endpoint = + process.env.CCTV_TORONTO_LIST_URL || TORONTO_RESCU_LIST_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json, text/javascript' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + // A response this loader will not read still owns its transport until the + // body is released, so every rejection path cancels before returning. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Toronto camera list redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Toronto camera download failed:', resp.status); + return discard(); + } + const text = await readResponseTextCapped(resp, TORONTO_MAX_CATALOG_BYTES); + const rows = parseRescuCameraList(text); + if (!rows.length) return []; + + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = torontoCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_TORONTO_MAX_SOURCES || DEFAULT_TORONTO_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(500, Math.floor(maxRaw))) + : DEFAULT_TORONTO_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, [ + TORONTO_DOWNTOWN, + ]); + console.log( + `[CCTV] Loaded Toronto camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Toronto camera download error:', + error?.message || error, + ); + return []; + } +} + +/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */ +const FLORIDA_DIRECTION_HEADINGS = Object.freeze({ + N: 0, + E: 90, + S: 180, + W: 270, +}); + +/** + * Stable camera id from a frame URL. FL511's ID column repeats across regions + * (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the + * key, as Calgary keys on its frame filename. Falls back to a path slug. + * + * @param {string} imageUrl - A host-pinned FL511 frame URL. + * @returns {?string} Provider-stable id, or null when underivable. + */ +export function floridaCameraId(imageUrl) { + let path; + try { + path = new URL(String(imageUrl ?? '')).pathname; + } catch { + return null; + } + const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path); + if (channel) return `fl-${channel[1]}`; + const slug = path + .replace(/^\/+|\.[a-z0-9]+$/gi, '') + .replace(/[^a-zA-Z0-9]+/g, '-') + .toLowerCase(); + return slug ? `fl-${slug}` : null; +} + +/** + * One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a + * one-letter code the free-text parser won't read, so a table maps it. + * + * @param {object} feature - ArcGIS feature from the FL511 camera layer. + * @returns {?object} + */ +export function floridaCameraToSource(feature) { + const a = feature?.attributes; + if (!a || typeof a !== 'object') return null; + const lat = toFiniteNumber(a.LATITUDE); + const lon = toFiniteNumber(a.LONGITUDE); + if (!isLikelyFloridaCoordinate(lat, lon)) return null; + + const imageUrl = String(a.IMAGE || ''); + let image; + try { + image = new URL(imageUrl); + } catch { + return null; + } + // Official image hosts only, HTTPS, no embedded credentials. + if ( + image.protocol !== 'https:' || + !FLORIDA_IMAGE_HOSTS.includes(image.host) || + image.username || + image.password + ) + return null; + + const cameraId = floridaCameraId(imageUrl); + if (!cameraId) return null; + + const heading = + FLORIDA_DIRECTION_HEADINGS[ + String(a.DIRECTION || '') + .trim() + .toUpperCase() + ]; + const hasHeading = Number.isFinite(heading); + + return { + id: cameraId, + name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`, + city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'), + cityId: 'florida', + provider: 'FL511', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: 5, // Coastal prior; the client's ground snap corrects. + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'fl511-open-data', + license: + 'FL511 (FDOT), individual personal use only; other use needs FDOT written consent', + }; +} + +/** + * Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged + * up to 2,000 rows at a time, ordered by the layer's object id so offsets are + * stable. Paging follows `exceededTransferLimit` and advances by the rows each + * page returned, so a lower server-side max record count still reads the whole + * layer. Frames are stills on the Divas Cloud hosts. A redirect on any page + * drops the pack; any other failed page keeps the rows already read. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadFloridaSourcesFromOpenData() { + try { + const cameras = []; + const seen = new Set(); + const pageSize = 2000; + let offset = 0; + // Page ceiling, in case the service ignores resultOffset. + for (let page = 0; ; page += 1) { + if (page >= FLORIDA_MAX_CATALOG_PAGES) { + console.warn( + `[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`, + ); + break; + } + // OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400). + const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`; + let resp; + try { + resp = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + } catch (error) { + console.warn( + '[CCTV] Florida catalog page failed:', + error?.message || error, + ); + break; + } + // Release the body on every path that will not read it. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Florida catalog redirected; redirects are not followed', + ); + await discard(); + return []; + } + if (!resp.ok) { + console.warn('[CCTV] Florida camera download failed:', resp.status); + await discard(); + break; + } + let body; + try { + body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES); + } catch (error) { + console.warn( + '[CCTV] Florida catalog page unreadable:', + error?.message || error, + ); + break; + } + const feats = Array.isArray(body?.features) ? body.features : []; + for (const feature of feats) { + const camera = floridaCameraToSource(feature); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + // ArcGIS sets exceededTransferLimit while more rows remain. A page may + // be shorter than pageSize when the layer's max record count is lower. + if (body?.exceededTransferLimit !== true || feats.length === 0) break; + offset += feats.length; + } + + const maxRaw = Number( + process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(4200, Math.floor(maxRaw))) + : DEFAULT_FLORIDA_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS); + console.log( + `[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Florida camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/server/providers/cctv/stream.js b/server/providers/cctv/stream.js index 14d1743bb..da811f5f9 100644 --- a/server/providers/cctv/stream.js +++ b/server/providers/cctv/stream.js @@ -70,7 +70,7 @@ export async function fetchHlsBytes( } } -export function sameOriginHlsUrl(value, base) { +function sameOriginHlsUrl(value, base) { const url = new URL(value, base); if ( !['https:', 'http:'].includes(url.protocol) || diff --git a/server/providers/cctv/taiwan.js b/server/providers/cctv/taiwan.js new file mode 100644 index 000000000..a14aabf9a --- /dev/null +++ b/server/providers/cctv/taiwan.js @@ -0,0 +1,303 @@ +import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js'; +import { + cameraDisplayCode, + fallbackHeadingFromId, + prioritizeSources, +} from './normalize.js'; +import { readResponseTextCapped } from '../common/http.js'; + +/** Freeway Bureau (MOTC) CCTV static table in the national MOTC 2.0 XML + * standard, published keyless and refreshed daily (~1,800 cameras). */ +export const TAIWAN_FREEWAY_CCTV_URL = + 'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml'; +/** The whole network is ~1.2 MB. */ +export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024; +const DEFAULT_TAIWAN_MAX_SOURCES = 600; +/** After a failed download, wait this long before asking again. The catalog + * host does not answer from some networks outside Taiwan, and every attempt + * there holds the shared catalog refresh until the connect timeout. */ +export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000; +/** The table is republished daily; a good copy stands in for a failed + * download for up to this long. */ +const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +// The table publishes each camera's MJPEG stream. The same camera servers +// answer a single JPEG for the same camera ID on a sibling path, which is what +// the frame proxy can relay; only these exact pairs are ever registered. +const STREAM_TO_FRAME = new Map([ + ['cctvn.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']], + ['cctvn5.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']], + ['cctvc.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']], + [ + 'cctvs.freeway.gov.tw', + ['/live-view/mjpg/video.cgi', '/live-view/jpg/image.cgi'], + ], +]); + +const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' }; + +/** Prioritization anchors, north to south, so a cap keeps the whole island + * rather than only greater Taipei. */ +const ANCHORS = [ + { lat: 25.033, lon: 121.5654 }, // Taipei + { lat: 24.9936, lon: 121.301 }, // Taoyuan + { lat: 24.8138, lon: 120.9675 }, // Hsinchu + { lat: 24.7021, lon: 121.7378 }, // Yilan + { lat: 24.1477, lon: 120.6736 }, // Taichung + { lat: 22.9999, lon: 120.227 }, // Tainan + { lat: 22.6273, lon: 120.3014 }, // Kaohsiung +]; + +/** Taiwan main island with slack for the coast; the freeway network has no + * offshore-island cameras. */ +function isLikelyTaiwanCoordinate(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + lat >= 21.8 && + lat <= 25.4 && + lon >= 119.9 && + lon <= 122.1 + ); +} + +const XML_ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}; + +function codePoint(value, radix) { + const code = parseInt(value, radix); + return Number.isInteger(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : ''; +} + +function decodeXml(text) { + return String(text ?? '') + .replace( + /&(?:(amp|lt|gt|quot|apos)|#(\d{1,7})|#x([0-9a-fA-F]{1,6}));/g, + (_, name, dec, hex) => + name ? XML_ENTITIES[name] : codePoint(dec ?? hex, dec ? 10 : 16), + ) + .trim(); +} + +function tag(block, name) { + const match = new RegExp(`<${name}>([^<]*)`).exec(block); + return match ? decodeXml(match[1]) : ''; +} + +/** + * Parse the MOTC 2.0 `CCTVList` document into flat rows. Regex-based like the + * Catalonia and Tarktee parsers: the schema is flat, and only the fields used + * below are read. + * + * @param {string} xml + * @returns {Array} + */ +export function parseTaiwanCctvXml(xml) { + const rows = []; + const blockRe = /([\s\S]*?)<\/CCTV>/g; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const block = match[1]; + rows.push({ + cctvId: tag(block, 'CCTVID'), + streamUrl: tag(block, 'VideoStreamURL'), + lat: Number(tag(block, 'PositionLat')), + lon: Number(tag(block, 'PositionLon')), + roadName: tag(block, 'RoadName'), + direction: tag(block, 'RoadDirection'), + sectionStart: tag(block, 'Start'), + sectionEnd: tag(block, 'End'), + mile: tag(block, 'LocationMile'), + }); + } + return rows; +} + +/** + * Published MJPEG stream URL -> the same camera's still, plus the host and + * camera number that identify it, or null. + * + * @param {string} streamUrl + * @returns {?{frameUrl: string, host: string, camera: string}} + */ +export function taiwanFrame(streamUrl) { + let url; + try { + url = new URL(String(streamUrl ?? '')); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.username || url.password || url.port) + return null; + const paths = STREAM_TO_FRAME.get(url.hostname); + if (!paths || url.pathname !== paths[0]) return null; + const camera = url.searchParams.get('camera') || ''; + if (!/^[A-Za-z0-9-]{1,40}$/.test(camera)) return null; + return { + // Reconstruct so extra query parameters cannot alter the request. + frameUrl: `https://${url.hostname}${paths[1]}?camera=${camera}`, + host: url.hostname.split('.')[0], + camera, + }; +} + +/** + * One parsed Freeway Bureau row -> one catalog source, or null. + * + * @param {object} row - Output of parseTaiwanCctvXml. + * @returns {?object} + */ +export function taiwanCameraToSource(row) { + if (!row || typeof row !== 'object') return null; + const { lat, lon } = row; + if (!isLikelyTaiwanCoordinate(lat, lon)) return null; + const frame = taiwanFrame(row.streamUrl); + if (!frame) return null; + const imageUrl = frame.frameUrl; + // CCTVID carries Chinese place names and a few IDs repeat across distinct + // cameras, so the stable key is the camera server plus its camera number. + const rawId = String(row.cctvId ?? '') + .trim() + .slice(0, 80); + const cameraId = `tw-freeway-${frame.host}-${frame.camera.toLowerCase()}`; + const road = String(row.roadName ?? '').trim(); + const direction = DIRECTIONS[String(row.direction ?? '').trim()] || ''; + const mile = String(row.mile ?? '').trim(); + const section = [row.sectionStart, row.sectionEnd] + .map((part) => String(part ?? '').trim()) + .filter(Boolean) + .join('→'); + // A bare carriageway letter is not a name; fall back to the published ID. + const name = + (road || mile || section + ? [road, direction, mile, section].filter(Boolean).join(' ') + : '') || + rawId || + `Freeway camera ${frame.camera}`; + + return { + id: cameraId, + name: name.slice(0, 160), + // One island-wide category in the camera picker, like Finland and Norway. + city: 'Taiwan / 臺灣', + cityId: 'taiwan', + provider: 'Freeway Bureau, MOTC', + lat, + lon, + // The table gives the carriageway direction, not the camera's bearing, + // so the pose uses the shared low-confidence prior. + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'tw-freeway-motc', + license: + 'Freeway Bureau, MOTC open data, Open Government Data License, Taiwan (Version 1.0)', + code: cameraDisplayCode( + `${road} ${mile}`.trim().toUpperCase() || frame.camera, + ), + }; +} + +let lastGood = null; +let lastGoodAt = 0; +let retryAfter = 0; + +/** Forget the last good table and any failure backoff (tests only). */ +export function resetTaiwanSourcesCache() { + lastGood = null; + lastGoodAt = 0; + retryAfter = 0; +} + +function fallbackSources() { + return lastGood && Date.now() - lastGoodAt <= TAIWAN_LAST_GOOD_MAX_AGE_MS + ? lastGood + : []; +} + +function taiwanSourceCap() { + const rawCap = Number( + process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES, + ); + return Number.isFinite(rawCap) + ? Math.max(1, Math.min(2000, Math.floor(rawCap))) + : DEFAULT_TAIWAN_MAX_SOURCES; +} + +/** + * Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table. + * One bounded request per shared catalog refresh. A failed download serves the + * last good table (up to a day old) and is not retried for an hour, so a host + * that cannot reach the Freeway Bureau does not wait on it every refresh. + * + * @returns {Promise>} + */ +export async function loadTaiwanSources() { + if (Date.now() < retryAfter) return fallbackSources(); + try { + const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); + const response = await fetch( + process.env.CCTV_TAIWAN_URL || TAIWAN_FREEWAY_CCTV_URL, + { + headers: { Accept: 'application/xml,text/xml' }, + redirect: 'manual', + signal, + }, + ); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error( + response.status >= 300 && response.status < 400 + ? 'catalog redirected; redirects are not followed' + : `HTTP ${response.status}`, + ); + } + const xml = await readResponseTextCapped( + response, + TAIWAN_MAX_CATALOG_BYTES, + signal, + ); + const cameras = new Map(); + for (const row of parseTaiwanCctvXml(xml)) { + const camera = taiwanCameraToSource(row); + if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera); + } + if (!cameras.size) throw new Error('no usable cameras in the table'); + const sources = prioritizeSources( + [...cameras.values()], + taiwanSourceCap(), + ANCHORS, + ); + console.log( + `[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`, + ); + lastGood = sources; + lastGoodAt = Date.now(); + retryAfter = 0; + return sources; + } catch (error) { + retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS; + const fallback = fallbackSources(); + console.warn( + `[CCTV] Taiwan camera download failed (${error?.message || error}); ` + + (fallback.length + ? `serving the last good ${fallback.length} cameras` + : 'no Taiwan cameras') + + ', next attempt in 1 h', + ); + return fallback; + } +} diff --git a/server/providers/common/request.js b/server/providers/common/request.js index 93bbbaa85..3a45bdd30 100644 --- a/server/providers/common/request.js +++ b/server/providers/common/request.js @@ -14,16 +14,34 @@ async function readRequestBodyCapped(req, maxBytes) { return Buffer.concat(chunks); } -function readRequestBody(req, maxBytes = 1024 * 1024) { +/** Optional cancellation discards buffered data while the request drains. */ +function readRequestBody(req, maxBytes = 1024 * 1024, signal) { return new Promise((resolve, reject) => { let total = 0; let bodyTooLarge = false; + let settled = false; const chunks = []; - req.on('data', (chunk) => { + const stopReading = () => { + if (!signal) return; + req.off('data', data); + req.off('end', end); + signal.removeEventListener('abort', abort); + }; + const fail = (error) => { + if (signal) { + if (settled) return; + settled = true; + chunks.length = 0; + signal.removeEventListener('abort', abort); + } + reject(error); + }; + const abort = () => fail(signal.reason); + const 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; + if (bodyTooLarge || settled) return; total += chunk.length; if (total > maxBytes) { // Same marker readRequestBodyCapped sets, so a caller can tell an @@ -32,15 +50,42 @@ function readRequestBody(req, maxBytes = 1024 * 1024) { chunks.length = 0; const error = new Error(`Request body exceeds ${maxBytes} bytes`); error.code = 'BODY_TOO_LARGE'; - reject(error); + fail(error); return; } chunks.push(chunk); - }); - req.on('end', () => { + }; + const end = () => { + if (settled) { + stopReading(); + return; + } if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8')); - }); - req.on('error', reject); + if (signal) { + settled = true; + chunks.length = 0; + stopReading(); + } + }; + const close = () => { + fail( + new DOMException('Request closed before its body ended', 'AbortError'), + ); + stopReading(); + req.off('error', fail); + req.off('close', close); + }; + if (signal) { + // Abort/overflow release retained bytes but keep draining. Retain an + // error handler until close, including a late error after end/abort. + req.on('close', close); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + } + req.on('data', data); + req.on('end', end); + req.on('error', fail); + if (signal && req.destroyed) close(); }); } diff --git a/server/providers/common/request.test.mjs b/server/providers/common/request.test.mjs new file mode 100644 index 000000000..0360e6a04 --- /dev/null +++ b/server/providers/common/request.test.mjs @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter, getEventListeners } from 'node:events'; +import { readRequestBody } from './request.js'; + +function fixture() { + const req = new EventEmitter(); + req.pause = () => assert.fail('The reader must keep draining'); + req.destroy = () => + assert.fail('The reader must preserve the response socket'); + const owner = new AbortController(); + return { req, owner }; +} + +function finish(req, owner) { + assert.doesNotThrow(() => req.emit('error', new Error('late stream error'))); + req.emit('end'); + assert.doesNotThrow(() => req.emit('error', new Error('error before close'))); + req.emit('close'); + for (const event of ['data', 'end', 'error', 'close']) + assert.equal(req.listenerCount(event), 0, event); + assert.equal(getEventListeners(owner.signal, 'abort').length, 0); +} + +test('default and cancellable readers preserve byte caps and UTF-8 across chunks', async () => { + for (const useSignal of [false, true]) { + for (const text of ['', 'hello', 'é']) { + const { req, owner } = fixture(); + const bytes = Buffer.from(text); + const body = readRequestBody( + req, + bytes.length, + useSignal ? owner.signal : undefined, + ); + for (const byte of bytes) req.emit('data', Buffer.from([byte])); + req.emit('end'); + assert.equal(await body, text); + if (useSignal) finish(req, owner); + } + } +}); + +test('default overflow rejects before end and drains without pausing or destroying', async () => { + const { req } = fixture(); + const rejection = assert.rejects(readRequestBody(req, 2), { + code: 'BODY_TOO_LARGE', + }); + req.emit('data', Buffer.from('é')); + req.emit('data', Buffer.from('!')); + await rejection; + req.emit('data', Buffer.alloc(4096)); + assert.doesNotThrow(() => req.emit('error', new Error('late'))); + req.emit('end'); +}); + +test('partial abort settles before end, discards late data and guards errors until close', async () => { + const { req, owner } = fixture(); + const reason = new Error('request deadline'); + const rejection = assert.rejects( + readRequestBody(req, 1024, owner.signal), + (error) => error === reason, + ); + req.emit('data', Buffer.from('partial')); + owner.abort(reason); + await rejection; + assert.equal(getEventListeners(owner.signal, 'abort').length, 0); + req.emit('data', Buffer.alloc(4096)); + finish(req, owner); +}); + +test('already-aborted and already-closed requests settle and release their listeners', async () => { + for (const closed of [false, true]) { + const { req, owner } = fixture(); + if (closed) req.destroyed = true; + else owner.abort(); + await assert.rejects(readRequestBody(req, 1024, owner.signal), { + name: 'AbortError', + }); + if (!closed) finish(req, owner); + for (const event of ['data', 'end', 'error', 'close']) + assert.equal(req.listenerCount(event), 0); + assert.equal(getEventListeners(owner.signal, 'abort').length, 0); + } +}); + +test('overflow keeps its first failure through later abort and stream errors', async () => { + const { req, owner } = fixture(); + const rejection = assert.rejects(readRequestBody(req, 2, owner.signal), { + code: 'BODY_TOO_LARGE', + }); + req.emit('data', Buffer.from('é!')); + await rejection; + owner.abort(new Error('later deadline')); + req.emit('data', Buffer.alloc(4096)); + finish(req, owner); +}); + +test('early close and request error reject once; successful end wins over later abort', async () => { + for (const event of ['close', 'error', 'end']) { + const { req, owner } = fixture(); + const reason = new Error('request failed'); + const body = readRequestBody(req, 32, owner.signal); + const observed = + event === 'end' + ? body.then((value) => assert.equal(value, 'ok')) + : assert.rejects( + body, + event === 'close' + ? { name: 'AbortError' } + : (error) => error === reason, + ); + req.emit('data', Buffer.from('ok')); + req.emit(event, reason); + owner.abort(new Error('later abort')); + await observed; + if (event !== 'close') finish(req, owner); + assert.equal(getEventListeners(owner.signal, 'abort').length, 0); + } +}); + +test('a prior data observer can abort synchronously without retaining or resolving that chunk', async () => { + const { req, owner } = fixture(); + const reason = new Error('observer stopped request'); + const external = () => owner.abort(reason); + req.on('data', external); + const rejection = assert.rejects( + readRequestBody(req, 1024, owner.signal), + (error) => error === reason, + ); + req.emit('data', Buffer.from('must be discarded')); + await rejection; + req.emit('end'); + req.emit('close'); + assert.deepEqual(req.listeners('data'), [external]); + assert.equal(getEventListeners(owner.signal, 'abort').length, 0); + req.off('data', external); +}); diff --git a/server/providers/common/same-site.js b/server/providers/common/same-site.js index 11e9d37b4..c5aeb610f 100644 --- a/server/providers/common/same-site.js +++ b/server/providers/common/same-site.js @@ -41,3 +41,48 @@ export function sameSiteGated(handler) { return handler(req, res, next); }; } + +/** + * Proxy header values arrive client-controlled; read one only when it is a + * single scheme token, never a comma-separated chain. + * @param {import('node:http').IncomingMessage} req + * @returns {'http:'|'https:'} + */ +function forwardedProtocol(req) { + if (req.socket?.encrypted) return 'https:'; + const proto = String(req.headers?.['x-forwarded-proto'] || '') + .trim() + .toLowerCase(); + return proto === 'https' ? 'https:' : 'http:'; +} + +/** + * Cross-site gate for keyless routes the map needs in order to render + * (`/api/terrain/heights`). It refuses the same cross-site browser shapes as + * `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign + * or opaque `Origin`) but does not refuse reverse-proxy forwarding headers: + * those protect credentials and spend, which a keyless route has neither of, + * and refusing them would break rendering for an instance served behind the + * reverse proxy the README documents. A single `X-Forwarded-Proto` supplies + * the scheme an `Origin` is compared against. That header cannot be attached + * to a cross-site browser request without a CORS preflight this server never + * answers, and `Sec-Fetch-Site` is still enforced regardless. + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').ServerResponse} res + * @returns {boolean} True when a 403 was sent. + */ +export function admitSameSiteKeyless(req, res) { + const verdict = admitSameSiteRequest({ + hostHeader: req.headers?.host, + protocol: forwardedProtocol(req), + origin: req.headers?.origin, + secFetchSite: req.headers?.['sec-fetch-site'], + proxyHeaders: {}, + }); + if (verdict.ok) return false; + res.statusCode = verdict.status; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify({ error: verdict.error })); + return true; +} diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js index fede1aade..5aab0f246 100644 --- a/server/providers/cyclones.js +++ b/server/providers/cyclones.js @@ -4,6 +4,8 @@ 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; +// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in +// msg() and translates it when shown (likewise 'Cyclone data unavailable'). const COVERAGE = 'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.'; const LAYERS = [ @@ -41,6 +43,14 @@ function number(value, min, max) { const n = Number(value); return Number.isFinite(n) && n >= min && n <= max ? n : null; } +// CurrentStorms.json reports movementSpeed in miles per hour (intensity is in +// knots); convert so `speedKt` means what it says. +function knotsFromMph(mph) { + return mph === null ? null : Math.round(mph * 0.868976); +} +// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it +// up to about half an hour before; allow that, but not a clock far ahead. +const EARLY_ADVISORY_MS = 90 * 60_000; function iso(value, now) { if ( typeof value !== 'string' || @@ -50,7 +60,7 @@ function iso(value, now) { const n = Date.parse(value); if ( !Number.isFinite(n) || - n > now + 5 * 60_000 || + n > now + EARLY_ADVISORY_MS || now - n > 12 * HOUR || new Date(n).toISOString().replace('.000Z', 'Z') !== value.replace('.000Z', 'Z') @@ -115,7 +125,7 @@ export function parseCycloneStatus(payload, now = Date.now()) { pressureHpa: number(raw.pressure, 800, 1100), movement: { directionDegrees: number(raw.movementDir, 0, 360), - speedKt: number(raw.movementSpeed, 0, 200), + speedKt: knotsFromMph(number(raw.movementSpeed, 0, 200)), }, advisoryUrl: officialLink(forecast.url), outlookUrl: raw.id.startsWith('al') diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js index 3a1905fc9..731852c2e 100644 --- a/server/providers/droneAirspace.js +++ b/server/providers/droneAirspace.js @@ -14,8 +14,12 @@ const API_URL = outFields: [ 'NAME', 'TYPE_CODE', - 'UPPER_DESC', - 'LOWER_DESC', + 'UPPER_VAL', + 'UPPER_UOM', + 'UPPER_CODE', + 'LOWER_VAL', + 'LOWER_UOM', + 'LOWER_CODE', 'TIMESOFUSE', 'CONT_AGENT', 'CITY', diff --git a/server/providers/firms.js b/server/providers/firms.js index 636968348..c73ca10ce 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; +import { readResponseTextCapped } from './common/http.js'; /** * NASA FIRMS live active-fire proxy with a memory + disk cache. @@ -35,6 +36,12 @@ export function firmsProxy() { 'VIIRS_SNPP_NRT', 'MODIS_NRT', ]; + /** + * Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about + * 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a + * bound, not a budget. + */ + const FIRMS_MAX_RESPONSE_BYTES = 64 * 1024 * 1024; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); const CACHE_PATH = path.join(CACHE_DIR, 'firms.json'); @@ -83,9 +90,12 @@ export function firmsProxy() { */ async function fetchSource(key, source) { const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; - const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const signal = AbortSignal.timeout(60_000); + const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const records = parseFirmsCsv(await res.text()); + const records = parseFirmsCsv( + await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal), + ); if (records === null) throw new Error('non-CSV upstream response'); return records; } diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 9741bb4e6..7594bc1bf 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -1,8 +1,10 @@ import { readResponseTextCapped } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { isAllowedGbfsHost, isAllowedGbfsPath, gbfsCacheControl, + gbfsUpstreamHeaders, } from '../../src/data/gbfsSource.js'; // --------------------------------------------------------------------------- @@ -11,8 +13,24 @@ import { /** Upstream fetch timeout for GBFS requests (ms). */ const GBFS_PROXY_TIMEOUT_MS = 12000; +/** Value of the Client-Identifier header sent with every upstream request. */ +export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view'; + export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB +/** + * Requests/minute/IP admitted by the proxy. + * + * The bikeshare layer's own demand is small and known: `src/layers/bikeshare/ + * registry.js` catalogues 15 systems, station information is cached per city + * after its first fetch, and station status refreshes once per city every + * `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every + * catalogued system in camera range at once that is about 15 requests a minute, + * so 120 is eight times a maximal real session while still capping a caller + * using this proxy as an open relay against a third-party GBFS host. + */ +export const GBFS_RATE_PER_MIN = 120; + function gbfsRedirectHost(location, requestUrl) { if (!location) return ''; try { @@ -60,6 +78,10 @@ export async function fetchGbfsUpstream( headers: { Accept: 'application/json', 'User-Agent': 'gods-eye-view-gbfs-proxy/1.0', + // Urban Sharing (the Norwegian city-bike feeds) asks every client to + // name itself in this header; other operators ignore it. + 'Client-Identifier': GBFS_CLIENT_IDENTIFIER, + ...gbfsUpstreamHeaders(new URL(url).hostname), }, redirect: 'manual', signal: controller.signal, @@ -113,7 +135,12 @@ export async function fetchGbfsUpstream( * * @returns {import('vite').Plugin} */ -export function gbfsProxy() { +export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) { + const allow = makeRateLimiter({ + windowMs: 60_000, + max: ratePerMinute, + globalMax: ratePerMinute * 3, + }); const installMiddleware = (server) => { server.middlewares.use('/api/gbfs', async (req, res) => { try { @@ -126,6 +153,19 @@ export function gbfsProxy() { return; } + // Frequency, checked before the target is even parsed: the allowlist + // already says WHERE this proxy may go, nothing said HOW OFTEN. A + // refused request performs no outbound fetch and starts no timer. + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '/', 'http://localhost'); const encodedTarget = url.pathname.replace(/^\/+/, ''); if (!encodedTarget) { @@ -181,7 +221,7 @@ export function gbfsProxy() { return; } - if (!isAllowedGbfsPath(upstreamUrl.pathname)) { + if (!isAllowedGbfsPath(upstreamUrl.pathname, upstreamUrl.hostname)) { res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', @@ -196,13 +236,26 @@ export function gbfsProxy() { } const upstream = await fetchGbfsUpstream(upstreamUrl.toString()); + // Only JSON is relayed, re-serialized under a fixed type: neither the + // upstream's text nor its declared type reaches the browser. + let payload; + try { + payload = JSON.parse(upstream.body); + } catch { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS upstream returned non-JSON' })); + return; + } res.writeHead(upstream.status, { - 'Content-Type': upstream.contentType, + 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': gbfsCacheControl(upstreamUrl.pathname), 'X-GBFS-Upstream': upstreamUrl.hostname, 'X-GBFS-Cache': 'MISS', }); - res.end(upstream.body); + res.end(JSON.stringify(payload)); } catch (error) { if (error?.name === 'AbortError') { res.writeHead(504, { diff --git a/server/providers/gemini.js b/server/providers/gemini.js new file mode 100644 index 000000000..b3fe29e80 --- /dev/null +++ b/server/providers/gemini.js @@ -0,0 +1,22 @@ +import { createGeminiTokenHandler } from './gemini/realtime.js'; + +/** Mint constrained Live API credentials; the permanent key stays on Node. */ +function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) { + function install(middlewares) { + middlewares.use( + '/api/gemini/token', + createGeminiTokenHandler({ ...realtime, annotationGuidance }), + ); + } + return { + name: 'gemini-live-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + +export { geminiLiveProxy }; diff --git a/server/providers/gemini/config.js b/server/providers/gemini/config.js new file mode 100644 index 000000000..4fa438d15 --- /dev/null +++ b/server/providers/gemini/config.js @@ -0,0 +1,53 @@ +import { realtimeInstructions } from '../openai/instructions.js'; +import { GEV_REALTIME_TOOLS } from '../openai/tools.js'; + +export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live'; +export const GEMINI_TOKEN_ENDPOINT = + 'https://generativelanguage.googleapis.com/v1beta/auth_tokens'; +export const GEMINI_LIVE_WEBSOCKET_URL = + 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained'; + +export function normalizeGeminiModel(value) { + const model = String(value ?? GEMINI_LIVE_MODEL_DEFAULT) + .trim() + .replace(/^models\//, ''); + return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null; +} + +/** REST BidiGenerateContentSetup fields, excluding its model resource name. */ +export function createGeminiLiveConfig( + annotationGuidance, + inputMode = 'open-mic', + tools = GEV_REALTIME_TOOLS, +) { + if (!['open-mic', 'push-to-talk'].includes(inputMode)) + throw new Error('Invalid Gemini input mode'); + const config = { + generationConfig: { responseModalities: ['AUDIO'] }, + systemInstruction: { + parts: [{ text: realtimeInstructions(annotationGuidance) }], + }, + tools: [ + { + functionDeclarations: tools.map( + ({ name, description, parameters }) => ({ + name, + description, + // GEV's canonical schemas are JSON Schema, not Google's Schema. + parametersJsonSchema: structuredClone(parameters), + // App actions must finish before the model confirms their result. + behavior: 'BLOCKING', + }), + ), + }, + ], + inputAudioTranscription: {}, + outputAudioTranscription: {}, + contextWindowCompression: { slidingWindow: {} }, + }; + if (inputMode === 'push-to-talk') + config.realtimeInputConfig = { + automaticActivityDetection: { disabled: true }, + }; + return config; +} diff --git a/server/providers/gemini/realtime.js b/server/providers/gemini/realtime.js new file mode 100644 index 000000000..dbbdba345 --- /dev/null +++ b/server/providers/gemini/realtime.js @@ -0,0 +1,208 @@ +import { waitForSignal } from '../../../src/sources/requestDeadline.js'; +import { clientKey, makeCostRateLimiter } from '../common/rate-limit.js'; +import { readResponseJsonCapped } from '../../../src/sources/httpBody.js'; +import { + GEMINI_TOKEN_ENDPOINT, + GEMINI_LIVE_WEBSOCKET_URL, + GEMINI_LIVE_MODEL_DEFAULT, + createGeminiLiveConfig, + normalizeGeminiModel, +} from './config.js'; +import { + isGeminiRequestOriginAllowed, + readGeminiTokenRequest, +} from './request.js'; + +const UPSTREAM_RESPONSE_BYTES = 32 * 1024; +const MINT_FAILURE = 'Gemini voice could not connect. Try again.'; + +export function createGeminiTokenHandler({ + annotationGuidance, + tools, + fetchImpl = (...args) => fetch(...args), + resolveApiKey = () => process.env.GEMINI_API_KEY, + resolveModel = () => + process.env.GEMINI_LIVE_MODEL || GEMINI_LIVE_MODEL_DEFAULT, + resolveHost = () => process.env.HOST, + resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS, + resolveRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN ?? '30', + now = Date.now, + timeoutMs = 15_000, +} = {}) { + let limiter; + return async (req, res) => { + if (req.aborted || res.destroyed || res.writableEnded) return; + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + const reply = (status, errorOrPayload) => { + if (res.destroyed || res.writableEnded) return; + res.statusCode = status; + if (status >= 400) res.setHeader('Connection', 'close'); + res.end( + JSON.stringify( + typeof errorOrPayload === 'string' + ? { error: errorOrPayload } + : errorOrPayload, + ), + ); + }; + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return reply(405, 'Method not allowed'); + } + let apiKey; + let model; + try { + if ( + !isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts()) + ) + return reply(403, 'Gemini voice requires a same-origin app request'); + apiKey = String(resolveApiKey() || '').trim(); + if (!apiKey) + return reply(503, 'Add a Gemini API key in Provider Settings'); + model = normalizeGeminiModel(resolveModel()); + if (!model) + return reply(503, 'The Gemini Live model configuration is invalid'); + if (limiter === undefined) + limiter = makeCostRateLimiter(resolveRateLimit(), 30); + if (limiter && !limiter(clientKey(req))) { + res.setHeader('Retry-After', '60'); + return reply( + 429, + 'Gemini voice is starting too often. Try again shortly.', + ); + } + } catch { + // Connect does not observe the handler's returned promise. A synchronous + // configuration resolver must fail with a bounded, fixed response. + return reply(503, MINT_FAILURE); + } + + const lifetime = new AbortController(); + let disconnected = false; + const disconnect = () => { + disconnected = true; + lifetime.abort(new DOMException('Request closed', 'AbortError')); + }; + const responseClosed = () => { + if (!res.writableEnded) disconnect(); + }; + req.once('aborted', disconnect); + res.once('close', responseClosed); + const deadline = setTimeout(() => { + lifetime.abort( + new DOMException('Token request timed out', 'TimeoutError'), + ); + }, timeoutMs); + deadline.unref?.(); + let acquiredResponse; + try { + const inputMode = await readGeminiTokenRequest(req, lifetime.signal); + lifetime.signal.throwIfAborted(); + const config = createGeminiLiveConfig( + annotationGuidance, + inputMode, + tools, + ); + const issuedAt = now(); + // REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK + // input shape, converted before transport. With no fieldMask the complete + // setup is locked to this server-owned configuration. + const request = { + uses: 1, + expireTime: new Date(issuedAt + 30 * 60_000).toISOString(), + newSessionExpireTime: new Date(issuedAt + 60_000).toISOString(), + bidiGenerateContentSetup: { model: `models/${model}`, ...config }, + }; + const acquisition = Promise.resolve( + fetchImpl(GEMINI_TOKEN_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: lifetime.signal, + headers: { + 'x-goog-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }), + ); + // A fetch implementation can ignore abort. Bound this caller's wait and + // discard a response that arrives after timeout/disconnection. + void acquisition.then( + (late) => { + acquiredResponse = late; + if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {}); + }, + () => {}, + ); + const response = await waitForSignal(acquisition, lifetime.signal); + lifetime.signal.throwIfAborted(); + if ( + response.redirected || + (response.url && response.url !== GEMINI_TOKEN_ENDPOINT) + ) { + void response.body?.cancel().catch(() => {}); + throw new Error('Unexpected token response destination'); + } + if (!response.ok) { + // Never forward upstream bodies, headers or exception messages. + void response.body?.cancel().catch(() => {}); + if (response.status === 429) { + res.setHeader('Retry-After', '60'); + return reply(429, 'Gemini quota is unavailable. Try again later.'); + } + if (response.status === 401 || response.status === 403) + return reply( + 503, + 'Gemini access was refused. Check the API key and model access.', + ); + if (response.status === 400 || response.status === 404) + return reply( + 502, + 'Gemini Live is unavailable for the configured model.', + ); + return reply(502, MINT_FAILURE); + } + const data = await readResponseJsonCapped( + response, + UPSTREAM_RESPONSE_BYTES, + lifetime.signal, + ); + lifetime.signal.throwIfAborted(); + if ( + !data || + Array.isArray(data) || + typeof data !== 'object' || + typeof data.name !== 'string' || + data.name.length > 8192 || + !/^auth_tokens\/[A-Za-z0-9._~+/=-]+$/.test(data.name) || + data.name.includes(apiKey) + ) + throw new Error('Invalid ephemeral token'); + reply(200, { + token: data.name, + model, + config, + websocketUrl: GEMINI_LIVE_WEBSOCKET_URL, + }); + } catch (error) { + if (disconnected || req.aborted) return; + if (error?.code === 'BODY_TOO_LARGE') + return reply(413, 'Token request is too large'); + if (error?.code === 'BAD_CONTENT_TYPE') + return reply(415, 'Use an empty JSON token request'); + if (error?.code === 'BAD_BODY') + return reply(400, 'Use an empty JSON token request'); + reply(lifetime.signal.aborted ? 504 : 502, MINT_FAILURE); + } finally { + // Abort can happen after acquisition resolves but before this handler + // resumes and passes ownership to the body reader. + if (lifetime.signal.aborted) + void acquiredResponse?.body?.cancel().catch(() => {}); + clearTimeout(deadline); + req.off('aborted', disconnect); + res.off('close', responseClosed); + } + }; +} diff --git a/server/providers/gemini/request.js b/server/providers/gemini/request.js new file mode 100644 index 000000000..3dde288d6 --- /dev/null +++ b/server/providers/gemini/request.js @@ -0,0 +1,79 @@ +import { readRequestBody } from '../common/request.js'; +import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs'; +import { + isAllowedHost, + resolveAllowedHosts, +} from '../../../build/allowedHosts.js'; + +/** Use the app's Host and request policies, with a required browser Origin. */ +export function isGeminiRequestOriginAllowed( + req, + configuredHost = '', + configuredAllowedHosts, +) { + const host = req.headers?.host; + const origin = req.headers?.origin; + if ( + typeof host !== 'string' || + typeof origin !== 'string' || + !host || + !origin || + /[\s/?#@\\]/.test(host) + ) + return false; + const protocol = req.socket?.encrypted ? 'https:' : 'http:'; + if ( + !admitSameSiteRequest({ + hostHeader: host, + protocol, + origin, + secFetchSite: req.headers?.['sec-fetch-site'], + proxyHeaders: req.headers || {}, + }).ok + ) + return false; + // The shared gate compares a present Origin exactly with this authority; + // this route also requires the Origin and an allowed Host. + const boundHost = String(configuredHost || '') + .trim() + .toLowerCase(); + return isAllowedHost(host, resolveAllowedHosts(configuredAllowedHosts), [ + boundHost, + ]); +} + +/** Admit only the existing input gesture; callers cannot supply model/setup. */ +export async function readGeminiTokenRequest(req, signal, maxBytes = 1024) { + const declared = Number(req.headers?.['content-length']); + const fail = (code) => + Object.assign(new Error('Invalid token request'), { code }); + const body = await readRequestBody( + req, + maxBytes, + Number.isFinite(declared) && declared > maxBytes + ? AbortSignal.abort(fail('BODY_TOO_LARGE')) + : signal, + ); + if (!body.trim()) return 'open-mic'; + if ( + !/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type'] || '') + ) + throw fail('BAD_CONTENT_TYPE'); + let parsed; + try { + parsed = JSON.parse(body); + } catch { + throw fail('BAD_BODY'); + } + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') + throw fail('BAD_BODY'); + const keys = Object.keys(parsed); + if (!keys.length) return 'open-mic'; + if ( + keys.length === 1 && + keys[0] === 'inputMode' && + parsed.inputMode === 'push-to-talk' + ) + return 'push-to-talk'; + throw fail('BAD_BODY'); +} diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js new file mode 100644 index 000000000..853fd35e3 --- /dev/null +++ b/server/providers/ghostMaps.js @@ -0,0 +1,437 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { + GHOSTMAPS_MAX_FILE_BYTES, + GHOSTMAPS_REPO, + buildGhostMapsCatalog, + ghostMapsRawUrl, + parseGitRefAdvertisement, +} from '../../src/layers/ghostMaps/catalog.js'; +import { + readResponseBytesCapped, + readResponseJsonCapped, + readResponseTextCapped, +} from './common/http.js'; + +// S2 Underground GhostMaps (public GitHub repository of KMZ intel products). +// The repository is not ours, so there is no webhook: the watcher reads the +// branch head through git's smart-HTTP ref advertisement (~0.5 KB, outside the +// REST API rate limit) every few seconds, and only a new commit costs REST +// calls (tree + commit). Browsers hear about a new commit over server-sent +// events and fetch only the products whose blob changed. Files are fetched by +// commit-pinned raw URLs and kept on disk by blob sha. + +const DEFAULT_POLL_MS = 15_000; +const MIN_POLL_MS = 5_000; +const FAILURE_BACKOFF_MS = [15_000, 30_000, 60_000, 120_000, 300_000]; +const TIMEOUT_MS = 15_000; +const FILE_TIMEOUT_MS = 120_000; +const HEARTBEAT_MS = 25_000; +const MAX_SUBSCRIBERS = 64; +const MAX_REF_BYTES = 64 * 1024; +const MAX_TREE_BYTES = 16 * 1024 * 1024; +const MAX_COMMIT_BYTES = 2 * 1024 * 1024; +const USER_AGENT = 'gods-eye-view-ghostmaps/1.0'; +const REPO_PATH = `${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}`; + +/** Poll interval from GHOSTMAPS_POLL_SECONDS, bounded below at 5 s. */ +export function ghostMapsPollMs(raw = process.env.GHOSTMAPS_POLL_SECONDS) { + const seconds = Number(raw); + if (!raw || !Number.isFinite(seconds)) return DEFAULT_POLL_MS; + return Math.max(MIN_POLL_MS, Math.round(seconds * 1000)); +} + +/** + * The repository watcher and file cache, independent of HTTP routing. + * @returns {{start: Function, stop: Function, catalog: Function, + * checkNow: Function, file: Function, subscribe: Function}} + */ +export function createGhostMapsWatcher({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), + pollMs = ghostMapsPollMs(), + cacheDir = path.join(process.cwd(), '.gev-cache', 'ghostmaps'), + token = () => String(process.env.GITHUB_TOKEN || '').trim(), + log = (...args) => console.log(...args), + warn = (...args) => console.warn(...args), +} = {}) { + let state = { + commit: null, + committedAt: null, + message: null, + products: [], + checkedAt: null, + changedAt: null, + error: null, + }; + let timer = null; + let running = false; + let checking = null; + let failures = 0; + let diskLoaded = false; + const listeners = new Set(); + const downloads = new Map(); + + const apiHeaders = () => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + ...(token() ? { Authorization: `Bearer ${token()}` } : {}), + }); + + async function get(url, headers, timeoutMs = TIMEOUT_MS) { + const response = await fetchImpl(url, { + headers, + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw Object.assign(new Error(`HTTP ${response.status} from ${url}`), { + status: response.status, + }); + } + return response; + } + + async function readHead() { + const response = await get( + `${GHOSTMAPS_REPO.url}.git/info/refs?service=git-upload-pack`, + { 'User-Agent': USER_AGENT }, + ); + const sha = parseGitRefAdvertisement( + await readResponseTextCapped(response, MAX_REF_BYTES), + ); + if (!sha) throw new Error('GhostMaps branch head not advertised'); + return sha; + } + + async function readCommit(sha) { + const api = `https://api.github.com/repos/${REPO_PATH}`; + const tree = await readResponseJsonCapped( + await get(`${api}/git/trees/${sha}?recursive=1`, apiHeaders()), + MAX_TREE_BYTES, + ); + if (!Array.isArray(tree?.tree)) throw new Error('GhostMaps tree malformed'); + if (tree.truncated) warn('[ghostmaps] repository tree listing truncated'); + let committedAt = null; + let message = null; + try { + const commit = await readResponseJsonCapped( + await get(`${api}/commits/${sha}`, apiHeaders()), + MAX_COMMIT_BYTES, + ); + committedAt = Date.parse(commit?.commit?.committer?.date) || null; + message = String(commit?.commit?.message || '') + .split('\n')[0] + .slice(0, 200); + } catch { + // The commit date is decoration; the tree is what matters. + } + return { products: buildGhostMapsCatalog(tree.tree), committedAt, message }; + } + + async function loadDisk() { + if (diskLoaded) return; + diskLoaded = true; + try { + const saved = JSON.parse( + await fsp.readFile(path.join(cacheDir, 'catalog.json'), 'utf8'), + ); + if (/^[0-9a-f]{40}$/.test(saved?.commit) && Array.isArray(saved.products)) + state = { ...state, ...saved, error: null }; + } catch { + /* first run */ + } + } + + async function saveDisk() { + try { + await fsp.mkdir(cacheDir, { recursive: true }); + const { commit, committedAt, message, products, changedAt } = state; + await fsp.writeFile( + path.join(cacheDir, 'catalog.json'), + JSON.stringify({ commit, committedAt, message, products, changedAt }), + ); + } catch (error) { + warn('[ghostmaps] could not save catalog:', error.message); + } + } + + function emit() { + const event = publicCatalog(); + for (const listener of listeners) { + try { + listener(event); + } catch { + /* a broken subscriber must not stop the rest */ + } + } + } + + async function check() { + await loadDisk(); + const head = await readHead(); + state.checkedAt = now(); + if (head === state.commit) { + if (state.error !== null) { + state.error = null; + emit(); + } + return false; + } + const next = await readCommit(head); + const previous = new Map(state.products.map((p) => [p.id, p.version])); + const changed = next.products + .filter((p) => previous.get(p.id) !== p.version) + .map((p) => p.id); + state = { + ...state, + ...next, + commit: head, + changedAt: now(), + error: null, + }; + log( + `[ghostmaps] commit ${head.slice(0, 8)}${ + next.message ? ` "${next.message}"` : '' + }; updated: ${changed.join(', ') || 'none'}`, + ); + await saveDisk(); + emit(); + // Warm the default products so the first browser load is local. + for (const product of state.products) + if (product.defaultOn) file(product.id).catch(() => {}); + return true; + } + + /** Run one check now, coalescing with one already in flight. */ + function checkNow() { + if (!checking) + checking = check() + .then( + (changed) => { + failures = 0; + return changed; + }, + (error) => { + failures += 1; + state.error = error.message; + state.checkedAt = now(); + warn('[ghostmaps] check failed:', error.message); + emit(); + return false; + }, + ) + .finally(() => { + checking = null; + }); + return checking; + } + + function schedule() { + clearTimeout(timer); + if (!running) return; + const delay = failures + ? FAILURE_BACKOFF_MS[Math.min(failures, FAILURE_BACKOFF_MS.length) - 1] + : pollMs; + timer = setTimeout(async () => { + await checkNow(); + schedule(); + }, delay); + timer.unref?.(); + } + + /** Start watching (idempotent); the first check runs immediately. */ + function start() { + if (running) return checking || Promise.resolve(false); + running = true; + const first = checkNow(); + first.then(schedule); + return first; + } + + function stop() { + running = false; + clearTimeout(timer); + timer = null; + } + + function publicCatalog() { + return { + repo: GHOSTMAPS_REPO.url, + branch: GHOSTMAPS_REPO.branch, + commit: state.commit, + committedAt: state.committedAt, + message: state.message, + checkedAt: state.checkedAt, + changedAt: state.changedAt, + pollMs, + error: state.error, + products: state.products.map( + ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }) => ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }), + ), + }; + } + + /** + * The bytes of one product's current edition, from disk or a commit-pinned + * download. Only catalog paths are ever fetched. + */ + async function file(id) { + const product = state.products.find((p) => p.id === id); + if (!product) return null; + const target = path.join(cacheDir, 'files', `${product.version}.kmz`); + try { + return { product, bytes: await fsp.readFile(target) }; + } catch { + /* not cached yet */ + } + const commit = state.commit; + let pending = downloads.get(product.version); + if (!pending) { + pending = (async () => { + const response = await get( + ghostMapsRawUrl(commit, product.path), + { 'User-Agent': USER_AGENT }, + FILE_TIMEOUT_MS, + ); + const bytes = Buffer.from( + await readResponseBytesCapped(response, GHOSTMAPS_MAX_FILE_BYTES), + ); + await fsp.mkdir(path.dirname(target), { recursive: true }); + const temp = `${target}.${process.pid}.tmp`; + await fsp.writeFile(temp, bytes); + await fsp.rename(temp, target); + await pruneFiles(); + return bytes; + })().finally(() => downloads.delete(product.version)); + downloads.set(product.version, pending); + } + return { product, bytes: await pending }; + } + + /** Keep only files that a current product still points to. */ + async function pruneFiles() { + const keep = new Set(state.products.map((p) => `${p.version}.kmz`)); + const dir = path.join(cacheDir, 'files'); + for (const name of await fsp.readdir(dir).catch(() => [])) { + if (!keep.has(name) && !name.endsWith('.tmp')) + await fsp.rm(path.join(dir, name), { force: true }).catch(() => {}); + } + } + + function subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + return { + start, + stop, + checkNow, + catalog: publicCatalog, + file, + subscribe, + subscribers: () => listeners.size, + }; +} + +/** Same-origin GhostMaps routes for dev and preview. */ +export function ghostMapsProxy({ watcher = createGhostMapsWatcher() } = {}) { + const json = (res, status, value) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + }); + res.end(JSON.stringify(value)); + }; + + function events(req, res) { + if (watcher.subscribers() >= MAX_SUBSCRIBERS) + return json(res, 429, { error: 'too_many_subscribers' }); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + const send = (catalog) => + res.write(`event: catalog\ndata: ${JSON.stringify(catalog)}\n\n`); + res.write('retry: 5000\n\n'); + send(watcher.catalog()); + const unsubscribe = watcher.subscribe(send); + const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); + heartbeat.unref?.(); + const close = () => { + clearInterval(heartbeat); + unsubscribe(); + }; + req.on('close', close); + res.on('error', close); + } + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const url = new URL(req.url || '/', 'http://local'); + const match = /^\/file\/([a-z0-9-]{1,40})$/.exec(url.pathname); + if (!['/catalog', '/events'].includes(url.pathname) && !match) + return json(res, 404, { error: 'unknown_route' }); + // Watching starts with the first request, so an install that never turns + // the layer on never contacts GitHub. + const first = watcher.start(); + if (url.pathname === '/catalog') { + if (!watcher.catalog().commit) await first; + return json(res, 200, watcher.catalog()); + } + if (url.pathname === '/events') return events(req, res); + try { + if (!watcher.catalog().commit) await first; + const found = await watcher.file(match[1]); + if (!found) return json(res, 404, { error: 'unknown_product' }); + const pinned = url.searchParams.get('v') === found.product.version; + res.writeHead(200, { + 'Content-Type': 'application/vnd.google-earth.kmz', + 'Content-Length': found.bytes.length, + 'X-GhostMaps-Version': found.product.version, + // A versioned URL names one immutable blob. + 'Cache-Control': pinned + ? 'private, max-age=31536000, immutable' + : 'no-store', + }); + return res.end(found.bytes); + } catch { + return json(res, 502, { error: 'ghostmaps_unavailable' }); + } + } + + return { + name: 'ghostmaps', + configureServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + }; +} diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs new file mode 100644 index 000000000..9723436d2 --- /dev/null +++ b/server/providers/ghostMaps.test.mjs @@ -0,0 +1,294 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + createGhostMapsWatcher, + ghostMapsPollMs, + ghostMapsProxy, +} from './ghostMaps.js'; + +const MASTER = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/Master Database/'; +const KINETIC = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz'; +const sha = (c) => c.repeat(40); + +/** A fake GitHub: ref advertisement, tree, commit and raw files. */ +function fakeGitHub() { + const repo = { head: sha('1'), trees: new Map(), calls: [] }; + repo.trees.set(sha('1'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_15_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('a'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + const body = (text, status = 200) => + new Response(text, { + status, + headers: { 'content-length': String(Buffer.byteLength(text)) }, + }); + const fetchImpl = async (url) => { + repo.calls.push(url); + if (url.endsWith('/info/refs?service=git-upload-pack')) + return body( + `001e# service=git-upload-pack\n0000\n003d${repo.head} refs/heads/main\n0000`, + ); + const tree = /git\/trees\/([0-9a-f]{40})/.exec(url); + if (tree) return body(JSON.stringify({ tree: repo.trees.get(tree[1]) })); + if (/\/commits\//.test(url)) + return body( + JSON.stringify({ + commit: { + message: 'Oct 18 Update\nmore', + committer: { date: '2026-10-18T00:00:00Z' }, + }, + }), + ); + if (url.startsWith('https://raw.githubusercontent.com/')) + return body('KMZ!'); + return body('nope', 404); + }; + return { repo, fetchImpl }; +} + +async function withCache(fn) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'ghostmaps-')); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// checkNow announces the catalog before background warm-up finishes. Join the +// same file reads before changing editions or removing the temporary cache. +async function warmDefaults(watcher) { + await Promise.all( + watcher + .catalog() + .products.filter((product) => product.defaultOn) + .map((product) => watcher.file(product.id)), + ); +} + +test('a new commit is announced once, naming only the products that changed', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + + assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); + const first = watcher.catalog(); + assert.equal(first.commit, sha('1')); + assert.equal(first.message, 'Oct 18 Update'); + assert.deepEqual( + first.products.map((p) => p.id), + ['cip', 'kinetic-30d'], + ); + assert.equal(events.length, 1); + + // Same head: one ref read, no REST calls, no event. + repo.calls.length = 0; + assert.equal(await watcher.checkNow(), false); + assert.equal(repo.calls.length, 1); + assert.equal(events.length, 1); + + // A new upload: the CIP gets a newer edition, the 30-day pull is untouched. + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + ...repo.trees.get(sha('1')), + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + ]); + assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); + assert.equal(events.length, 2); + const cip = events[1].products.find((p) => p.id === 'cip'); + assert.equal(cip.version, sha('c')); + assert.match(cip.fileName, /October_18/); + assert.equal( + events[1].products.find((p) => p.id === 'kinetic-30d').version, + sha('b'), + ); + // Repository paths stay on the server. + assert.equal('path' in cip, false); + watcher.stop(); + })); + +test('files download by commit-pinned URL, cache by blob, and stale blobs are pruned', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + await watcher.checkNow(); + const found = await watcher.file('kinetic-30d'); + assert.equal(found.bytes.toString(), 'KMZ!'); + const raw = repo.calls.filter((url) => + url.includes('raw.githubusercontent'), + ); + assert.ok(raw.every((url) => url.includes(`/GhostMaps/${sha('1')}/`))); + assert.ok( + raw.some((url) => url.endsWith('/Kinetic_Activities_Last_30_Days.kmz')), + ); + const before = repo.calls.length; + await watcher.file('kinetic-30d'); + assert.equal(repo.calls.length, before, 'second read comes from disk'); + assert.equal(await watcher.file('not-a-product'), null); + + // When the edition changes, the old blob leaves the disk cache. + await watcher.file('cip'); + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + await watcher.checkNow(); + await watcher.file('cip'); + const files = (await readdir(path.join(cacheDir, 'files'))).sort(); + assert.deepEqual(files, [`${sha('b')}.kmz`, `${sha('c')}.kmz`]); + watcher.stop(); + })); + +test('the catalog survives a restart from disk, and upstream failures are reported', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + const quiet = { log: () => {}, warn: () => {} }; + const online = createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }); + await online.checkNow(); + await warmDefaults(online); + const offline = createGhostMapsWatcher({ + fetchImpl: async () => new Response('down', { status: 503 }), + cacheDir, + ...quiet, + }); + assert.equal(await offline.checkNow(), false); + const catalog = offline.catalog(); + assert.equal(catalog.commit, sha('1')); + assert.equal(catalog.products.length, 2); + assert.match(catalog.error, /HTTP 503/); + })); + +test('an unchanged head clears an upstream outage and announces recovery once', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + let unavailable = false; + let checkedAt = 1000; + const requests = []; + const watcher = createGhostMapsWatcher({ + cacheDir, + now: () => checkedAt, + fetchImpl: async (url, options) => { + requests.push(url); + if (unavailable) return new Response('down', { status: 503 }); + return fetchImpl(url, options); + }, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + await watcher.checkNow(); + await Promise.all([watcher.file('cip'), watcher.file('kinetic-30d')]); + const healthy = watcher.catalog(); + assert.equal(events.length, 1); + + unavailable = true; + checkedAt = 2000; + assert.equal(await watcher.checkNow(), false); + assert.match(watcher.catalog().error, /HTTP 503/); + assert.equal(events.length, 2); + + unavailable = false; + checkedAt = 3000; + requests.length = 0; + assert.equal(await watcher.checkNow(), false, 'no new edition arrived'); + const recovered = watcher.catalog(); + assert.equal(recovered.error, null); + assert.equal(recovered.checkedAt, checkedAt); + assert.equal(recovered.commit, healthy.commit); + assert.equal(recovered.changedAt, healthy.changedAt); + assert.deepEqual(recovered.products, healthy.products); + assert.equal(requests.length, 1, 'recovery needs only the ref read'); + assert.match(requests[0], /\/info\/refs\?service=git-upload-pack$/); + assert.equal(events.length, 3, 'subscribers see the recovered catalog'); + assert.equal(events[2].error, null); + assert.equal(events[2].checkedAt, checkedAt); + + checkedAt = 4000; + assert.equal(await watcher.checkNow(), false); + assert.equal(events.length, 3, 'healthy unchanged polls stay silent'); + watcher.stop(); + })); + +test('poll interval is configurable with a 5 s floor', () => { + assert.equal(ghostMapsPollMs(undefined), 15000); + assert.equal(ghostMapsPollMs('30'), 30000); + assert.equal(ghostMapsPollMs('1'), 5000); + assert.equal(ghostMapsPollMs('soon'), 15000); +}); + +test('routes: unknown paths and methods are refused without contacting GitHub', async () => { + let started = 0; + const watcher = { + start: () => { + started += 1; + return Promise.resolve(false); + }, + catalog: () => ({ commit: sha('1'), products: [] }), + file: async () => null, + subscribe: () => () => {}, + subscribers: () => 0, + }; + let handler; + ghostMapsProxy({ watcher }).configureServer({ + middlewares: { use: (_route, fn) => (handler = fn) }, + }); + const call = async (method, url) => { + const res = { + destroyed: false, + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = String(body ?? ''); + }, + }; + await handler({ method, url }, res); + return res; + }; + assert.equal((await call('GET', '/nope')).status, 404); + assert.equal((await call('POST', '/catalog')).status, 405); + assert.equal((await call('GET', '/file/../../etc')).status, 404); + assert.equal(started, 0); + assert.equal((await call('GET', '/file/cip')).status, 404); + const catalog = await call('GET', '/catalog'); + assert.equal(catalog.status, 200); + assert.equal(JSON.parse(catalog.body).commit, sha('1')); + assert.equal(started, 2); +}); diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js new file mode 100644 index 000000000..adf28cea5 --- /dev/null +++ b/server/providers/gnssIntegrity.js @@ -0,0 +1,238 @@ +import { + GNSS_CLASSIFIER, + GNSS_WINDOW_MS, + normalizeGnssAircraft, +} from '../../src/layers/gnss/records.js'; +import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; +import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; + +// adsb.lol regional snapshot (keyless, ODbL). Only the integrity fields the +// GNSS layer needs leave this proxy, so the browser never sees the full feed. +// This is a separate read from the flights fallback on purpose: that point +// cache keeps only the fields the flights layer renders (no nic / nac_p), is +// keyed on 0.25° anchors and lives for 12 s, while integrity cells need +// whole-degree anchors held for a minute. +const RADIUS_NM = 250; +const CACHE_MS = 60_000; +const CACHE_MAX = 64; +/** Default 429 cooldown when upstream sends no usable Retry-After. */ +const COOLDOWN_MS = 60_000; +/** Bound sane upstream deadlines to one hour; never shorten a deadline within that range. */ +const COOLDOWN_MIN_MS = 5_000; +const COOLDOWN_MAX_MS = 60 * 60_000; +const FAILURE_COOLDOWN_MS = 15_000; +const MAX_IN_FLIGHT = 8; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +const TIMEOUT_MS = 10_000; + +/** Snap a view anchor to a whole degree so nearby views share one upstream read. */ +export function gnssIntegrityAnchor(url) { + const anchor = adsbLolFallbackAnchor({ url }); + if (!anchor) return null; + return { + lat: Math.round(anchor.latitude), + lon: Math.round(anchor.longitude), + }; +} + +/** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */ +export function gnssRetryCooldownMs(raw, nowMs) { + if (raw) { + const seconds = /^\d+(?:\.\d+)?$/.test(raw.trim()) ? Number(raw) : NaN; + const ms = seconds * 1000; + if (Number.isFinite(seconds) && seconds > 0) + return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms)); + const at = + Number.isNaN(seconds) && /[a-z]/i.test(raw) ? Date.parse(raw) : NaN; + if (Number.isFinite(at) && at > nowMs) + return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, at - nowMs)); + } + return COOLDOWN_MS; +} + +/** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */ +export function gnssIntegrityProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), + monotonicNow = () => performance.now(), + warn = (...args) => console.warn(...args), +} = {}) { + const cache = new Map(); + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 }); + let cooldownUntil = 0; + let cooldownStatus = 429; + + async function load({ lat, lon }) { + if (monotonicNow() < cooldownUntil) + throw Object.assign(new Error('cooling_down'), { + status: cooldownStatus, + }); + const signal = AbortSignal.timeout(TIMEOUT_MS); + try { + const response = await fetchImpl( + `https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`, + { + signal, + redirect: 'error', + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-gnss-integrity/1.0', + }, + }, + ); + if (!response.ok) { + await response.body?.cancel(); + if (response.status === 429) { + const at = monotonicNow(); + const raw = response.headers.get('retry-after'); + const delay = gnssRetryCooldownMs(raw, now()); + if (delay === COOLDOWN_MAX_MS) + warn('[gnss-integrity] upstream cooldown capped at one hour'); + cooldownStatus = 429; + cooldownUntil = Math.max(cooldownUntil, at + delay); + throw Object.assign(new Error('upstream_rate_limited'), { + status: 429, + }); + } + throw new Error('upstream_unavailable'); + } + const payload = await readResponseJsonCapped( + response, + MAX_RESPONSE_BYTES, + signal, + ); + const rows = normalizeGnssAircraft(payload); + if (!rows) throw new Error('invalid_snapshot'); + const sourceNow = payload.now; + if (!Number.isFinite(sourceNow) || sourceNow <= 0) + throw new Error('undated_snapshot'); + const fetchedAt = Math.min( + now(), + sourceNow > 10_000_000_000 ? sourceNow : sourceNow * 1000, + ); + if (now() - fetchedAt > GNSS_WINDOW_MS) + throw new Error('expired_snapshot'); + return { + fetchedAt, + anchor: { lat, lon }, + radiusNm: RADIUS_NM, + // `degraded` on each row is this classifier's verdict, not gpsjam's. + classifier: GNSS_CLASSIFIER.id, + rows, + }; + } catch (error) { + if (error.status !== 429) { + const until = monotonicNow() + FAILURE_COOLDOWN_MS; + if (until > cooldownUntil) { + cooldownUntil = until; + cooldownStatus = 502; + } + } + throw error; + } + } + + function age(entry) { + if (!entry) return Infinity; + const elapsed = monotonicNow() - entry.savedAt; + // A backwards injected clock cannot rejuvenate a snapshot. + return elapsed < 0 ? Infinity : entry.ageAtReceipt + elapsed; + } + + async function acquire(anchor, peer) { + const key = `${anchor.lat},${anchor.lon}`; + const previous = cache.get(key); + if (age(previous) < CACHE_MS) + return { value: previous.value, ageMs: age(previous), stale: false }; + try { + if (!inFlight.has(key) && inFlight.size >= MAX_IN_FLIGHT) + throw Object.assign(new Error('busy'), { status: 429, local: true }); + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + if (monotonicNow() >= cooldownUntil && !allow(peer)) + throw Object.assign(new Error('rate_limited'), { + status: 429, + local: true, + }); + const value = await load(anchor); + const entry = { + value, + savedAt: monotonicNow(), + ageAtReceipt: Math.max(0, now() - value.fetchedAt), + }; + cache.delete(key); + cache.set(key, entry); + while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); + return entry; + }); + const entry = await promise; + const ageMs = age(entry); + return { value: entry.value, ageMs, stale: ageMs >= CACHE_MS }; + } catch (error) { + if (age(previous) <= GNSS_WINDOW_MS) + return { value: previous.value, ageMs: age(previous), stale: true }; + throw error; + } + } + + async function handler(req, res) { + const json = (status, value, stale = false, retryAfterS = 60) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...([429, 502].includes(status) + ? { 'Retry-After': String(retryAfterS) } + : {}), + ...(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]; + if (path !== '/' && path !== '') + return json(404, { error: 'unknown_route' }); + const anchor = gnssIntegrityAnchor(req.url); + if (!anchor) return json(400, { error: 'invalid_anchor' }); + try { + const { value, stale, ageMs } = await acquire(anchor, clientKey(req)); + // ageMs is measured on this server's clock, so the browser can date + // the rows without comparing its clock to ours. + json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale); + } catch (error) { + if (error.status === 429) { + // Tell the browser how long the shared upstream cooldown has left. + const retryAfterS = Math.max( + 1, + error.local ? 60 : Math.ceil((cooldownUntil - monotonicNow()) / 1000), + ); + return json( + 429, + { + error: error.local ? error.message : 'gnss_integrity_rate_limited', + }, + false, + retryAfterS, + ); + } + json( + 502, + { error: 'gnss_integrity_unavailable' }, + false, + Math.max(1, Math.ceil((cooldownUntil - monotonicNow()) / 1000)), + ); + } + } + + return { + name: 'gnss-integrity', + configureServer({ middlewares }) { + middlewares.use('/api/gnss-integrity', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/gnss-integrity', handler); + }, + }; +} diff --git a/server/providers/local-receivers.js b/server/providers/local-receivers.js index 994da7bd8..f3f340b54 100644 --- a/server/providers/local-receivers.js +++ b/server/providers/local-receivers.js @@ -27,13 +27,13 @@ import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js'; */ 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; +const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; +const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); +const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; +const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; +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 LOCAL_RECEIVER_STALE_MS = 10_000; const MAX_FEEDS = 8; const LOG_PREFIX = '[local-receivers]'; @@ -139,7 +139,7 @@ class FeedError extends Error { * @param {string} address Resolved IP address. * @returns {boolean} */ -export function isLocalReceiverAddress(address) { +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); @@ -152,7 +152,7 @@ export function isLocalReceiverAddress(address) { * @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible. * @returns {Promise>} */ -export async function resolveLocalReceiverAddresses(hostname, lookupImpl) { +async function resolveLocalReceiverAddresses(hostname, lookupImpl) { let resolved; try { resolved = await lookupImpl(hostname, { all: true, verbatim: true }); @@ -274,7 +274,7 @@ function fetchLocalReceiverFeed(url, options) { * @param {object} options * @returns {Promise} Parsed JSON document. */ -export async function fetchLocalReceiverDocument( +async function fetchLocalReceiverDocument( url, { fetchImpl = fetchLocalReceiverFeed, diff --git a/server/providers/local.js b/server/providers/local.js index e976455ed..305a5c091 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -19,20 +19,23 @@ import { adsbLolProxy } from './aircraft/adsb-lol.js'; import { aisLiveProxy } from './vessels/ais-live.js'; import { trackBackfillProxies } from './aircraft/tracks.js'; import { openAiRealtimeProxy } from './openai.js'; +import { geminiLiveProxy } from './gemini.js'; import { googlePlacesContextProxy } from './places.js'; import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; +import { gnssIntegrityProxy } from './gnssIntegrity.js'; +import { ghostMapsProxy } from './ghostMaps.js'; import { droneAirspaceProxy } from './droneAirspace.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; /** * Construct the local provider plugins in their established order. - * `realtime` configures the voice session token endpoint. + * `realtime` configures OpenAI; `gemini` explicitly opts into Gemini Live. */ -function localProviderPlugins({ realtime } = {}) { +function localProviderPlugins({ realtime, gemini } = {}) { return [ openSkyProxy(), celestrakProxy(), @@ -55,12 +58,15 @@ function localProviderPlugins({ realtime } = {}) { aisLiveProxy(), trackBackfillProxies(), openAiRealtimeProxy({ realtime }), + ...(gemini ? [geminiLiveProxy({ realtime: gemini })] : []), googlePlacesContextProxy(), windProxy(), weatherProxy(), cycloneProxy(), mapillaryProxy(), firePerimetersProxy(), + gnssIntegrityProxy(), + ghostMapsProxy(), droneAirspaceProxy(), keySetupEndpoint(), ]; @@ -83,6 +89,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js'; export { googlePlacesContextProxy } from './places.js'; export { googleServerApiKey } from './places.js'; export { keylessGooglePlacesResponse } from './places.js'; +export { keylessGoogleGeocodeResponse } from './places.js'; +export { validateGeocodeAddress } from './places.js'; +export { geocodeBounds } from './places.js'; export { adsbLolFallbackAnchor } from './aircraft/opensky.js'; export { readResponseTextCapped } from './common/http.js'; export { readResponseJsonCapped } from './common/http.js'; diff --git a/server/providers/mapillary.js b/server/providers/mapillary.js index 401be28ae..9ee5bdb6c 100644 --- a/server/providers/mapillary.js +++ b/server/providers/mapillary.js @@ -1,19 +1,106 @@ -import { installMapillaryRoutes } from './mapillary/routes.js'; +import { sameSiteGated } from './common/same-site.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { + fetchTile, + mapillaryToken, + parseTilePath, + retryAfterWait, + TileUpstreamError, +} from './mapillary/tiles.js'; /** - * Vite plugin: cached Mapillary coverage tiles (token added server-side) and a - * status route that says whether a token is configured. + * Tile requests per client IP per minute, cache hits included: over 60 new + * 9-tile views a minute, more than a person flying the camera reaches. */ -function mapillaryProxy() { - return { - name: 'mapillary-proxy', - configureServer(server) { - installMapillaryRoutes(server.middlewares); - }, - configurePreviewServer(server) { - installMapillaryRoutes(server.middlewares); - }, - }; +export const TILE_ROUTE_MAX_PER_MIN = 600; + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(payload)); } -export { mapillaryProxy }; +/** GET /api/mapillary/status — whether a token is configured, never its value. */ +function handleStatus(req, res) { + if (req.method !== 'GET') + return sendJson(res, 405, { error: 'Method not allowed' }); + sendJson(res, 200, { configured: Boolean(mapillaryToken()) }); +} + +/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — trimmed protobuf tile. */ +async function handleTile(req, res, allow) { + if (req.method !== 'GET') + return sendJson(res, 405, { error: 'Method not allowed' }); + if (!allow(clientKey(req))) { + res.setHeader('Retry-After', '5'); + return sendJson(res, 429, { + error: 'Too many tile requests', + retryAfter: 5, + }); + } + const address = parseTilePath((req.url || '').split('?')[0]); + if (!address) + return sendJson(res, 400, { + error: 'Tile path must be /coverage/{z}/{x}/{y} with z 11–14', + }); + if (!mapillaryToken()) + return sendJson(res, 503, { error: 'no_key', keyRequired: true }); + // A client that leaves (a tile the camera moved past) cancels its fetch. + const left = new AbortController(); + res.on?.('close', () => { + if (!res.writableEnded) left.abort(); + }); + try { + const { bytes, source } = await fetchTile(address, { signal: left.signal }); + res.statusCode = bytes.length ? 200 : 204; + res.setHeader('Content-Type', 'application/x-protobuf'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.setHeader('X-Gev-Cache', source); + res.end(bytes.length ? bytes : undefined); + } catch (error) { + if (left.signal.aborted) return; + const status = error instanceof TileUpstreamError ? error.status : 0; + // A rejected token is a key problem the panel can name, not a fault. + if (status === 401 || status === 403) + return sendJson(res, 403, { + error: 'Mapillary rejected the access token', + keyRejected: true, + }); + if (status === 429) { + const retryAfter = retryAfterWait(error.retryAfter); + res.setHeader('Retry-After', String(retryAfter)); + return sendJson(res, 429, { + error: 'Mapillary is rate-limiting tile requests', + retryAfter, + }); + } + // An upstream 400 must not read as a malformed request to this proxy. + sendJson(res, 502, { error: error?.message || 'Tile fetch failed' }); + } +} + +/** + * Vite plugin: Mapillary coverage tiles with the token added server-side, and + * a status route. Both refuse cross-site requests so another page cannot + * spend the token. + */ +export function mapillaryProxy() { + const install = (middlewares) => { + const allow = makeRateLimiter({ + windowMs: 60_000, + max: TILE_ROUTE_MAX_PER_MIN, + globalMax: TILE_ROUTE_MAX_PER_MIN * 4, + }); + middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus)); + middlewares.use( + '/api/mapillary/tiles', + sameSiteGated((req, res) => handleTile(req, res, allow)), + ); + }; + return { + name: 'mapillary-proxy', + configureServer: (server) => install(server.middlewares), + configurePreviewServer: (server) => install(server.middlewares), + }; +} diff --git a/server/providers/mapillary/constants.js b/server/providers/mapillary/constants.js deleted file mode 100644 index 4b4e0b624..000000000 --- a/server/providers/mapillary/constants.js +++ /dev/null @@ -1,72 +0,0 @@ -import path from 'node:path'; - -/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */ -export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp'; - -/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */ -export const TILE_LAYERS = Object.freeze({ - // Region-sized z6–10 tiles are refused; the app never asks for them. The - // z14 `image` layer is ~98% of a tile and unused (positions come from the - // graph API), so it is dropped in transit. - coverage: Object.freeze({ - upstream: 'mly1_public', - zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]), - dropLayers: Object.freeze(['image']), - }), -}); - -/** Disk cache root, alongside the other providers' caches. */ -export const MAPILLARY_CACHE_DIR = path.join( - process.cwd(), - '.gev-cache', - 'mapillary', -); -export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles'); - -/** - * Longer than the 15-minute upstream Cache-Control: tiles change only when new - * imagery is processed, and a day avoids re-downloading a city per camera move. - */ -export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000; - -/** - * A tile write starts a background sweep at most this often: expired files go - * first, then the oldest until the cache fits in TILE_DISK_MAX_BYTES. - */ -export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000; -export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024; -/** Per-file cost on top of its size, so empty tiles still count. */ -export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096; - -/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */ -export const TILE_MAX_BYTES = 48 * 1024 * 1024; - -export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; -/** Per-entry cost on top of its bytes, so empty tiles still count. */ -export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024; -export const TILE_FETCH_TIMEOUT_MS = 60_000; -/** - * Upstream fetches at once; further misses queue. A view needs at most 25 - * tiles, and the cap stops a burst of misses opening thousands of requests. - */ -export const TILE_UPSTREAM_CONCURRENCY = 6; -/** - * Tile requests per client IP per minute, cache hits included. 600 is 24 new - * 25-tile views a minute, more than a person flying the camera reaches, but it - * stops one page draining the shared token into a Mapillary rate limit. - */ -export const TILE_ROUTE_MAX_PER_MIN = 600; -/** - * How long a 401/403 answers tile misses before asking Mapillary again. A - * changed token is tried at once. - */ -export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000; -/** After a 429 without a usable Retry-After, hold tile misses this long. */ -export const TILE_RATE_LIMIT_HOLD_MS = 60_000; -/** The longest Retry-After honoured, so a bad header cannot stall coverage. */ -export const TILE_RATE_LIMIT_MAX_HOLD_MS = 10 * 60 * 1000; - -/** The client token lives in the browser by design; the server adds it to tile URLs too. */ -export function mapillaryToken() { - return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim(); -} diff --git a/server/providers/mapillary/routes.js b/server/providers/mapillary/routes.js deleted file mode 100644 index 1721dd40a..000000000 --- a/server/providers/mapillary/routes.js +++ /dev/null @@ -1,95 +0,0 @@ -import { sameSiteGated } from '../common/same-site.js'; -import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; -import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js'; -import { mapillaryToken, TILE_ROUTE_MAX_PER_MIN } from './constants.js'; - -function sendJson(res, status, payload) { - res.statusCode = status; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.setHeader('Cache-Control', 'no-store'); - res.end(JSON.stringify(payload)); -} - -/** GET /api/mapillary/status — whether a token is configured, never its value. */ -function handleStatus(req, res) { - if (req.method !== 'GET') - return sendJson(res, 405, { error: 'Method not allowed' }); - sendJson(res, 200, { configured: Boolean(mapillaryToken()) }); -} - -/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — cached protobuf tile. */ -async function handleTile(req, res, allow) { - if (req.method !== 'GET') - return sendJson(res, 405, { error: 'Method not allowed' }); - if (!allow(clientKey(req))) { - res.setHeader('Retry-After', '5'); - return sendJson(res, 429, { - error: 'Too many tile requests', - retryAfter: 5, - }); - } - const match = /^\/(coverage)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec( - (req.url || '').split('?')[0], - ); - if (!match) - return sendJson(res, 400, { - error: 'Tile path must be /coverage/{z}/{x}/{y}', - }); - if (!mapillaryToken()) - return sendJson(res, 503, { error: 'no_key', keyRequired: true }); - const abandoned = new AbortController(); - req.on?.('aborted', () => abandoned.abort()); - res.on?.('close', () => abandoned.abort()); - try { - const { bytes, source } = await fetchTile( - { layer: match[1], z: match[2], x: match[3], y: match[4] }, - { signal: abandoned.signal }, - ); - if (res.writableEnded) return; - res.statusCode = bytes.length ? 200 : 204; - res.setHeader('Content-Type', 'application/x-protobuf'); - res.setHeader('Cache-Control', 'public, max-age=3600'); - res.setHeader('X-Gev-Cache', source); - res.end(bytes.length ? bytes : undefined); - } catch (error) { - if (res.writableEnded || abandoned.signal.aborted) return; - if (error instanceof TileRequestError) - return sendJson(res, error.status, { error: error.message }); - if (error instanceof TileUpstreamError) { - // A rejected token is a key problem the panel can name, not a fault. - if (error.keyRejected) - return sendJson(res, 403, { - error: 'Mapillary rejected the access token', - keyRejected: true, - }); - if (error.status === 429) { - const retryAfter = error.retryAfterSec || 60; - res.setHeader('Retry-After', String(retryAfter)); - return sendJson(res, 429, { - error: 'Mapillary is rate-limiting tile requests', - retryAfter, - }); - } - // An upstream 400 must not read as a malformed request to this proxy. - return sendJson(res, 502, { error: error.message }); - } - sendJson(res, 502, { error: error?.message || 'Tile fetch failed' }); - } -} - -/** - * Attach the Mapillary routes. Both refuse cross-site requests so another page - * cannot spend the token or provoke a 429 that holds every tile miss. - */ -export function installMapillaryRoutes(middlewares) { - const allow = makeRateLimiter({ - windowMs: 60_000, - max: TILE_ROUTE_MAX_PER_MIN, - globalMax: TILE_ROUTE_MAX_PER_MIN * 4, - }); - middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus)); - middlewares.use( - '/api/mapillary/tiles', - sameSiteGated((req, res) => handleTile(req, res, allow)), - ); -} diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 832e8e483..6e2207848 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -1,518 +1,265 @@ -import path from 'node:path'; -import { promises as fsp } from 'node:fs'; import { readResponseBytesCapped } from '../common/http.js'; import { stripTileLayers } from './trim.js'; -import { - MAPILLARY_TILE_HOST, - TILE_LAYERS, - TILE_DISK_DIR, - TILE_DISK_TTL_MS, - TILE_DISK_SWEEP_INTERVAL_MS, - TILE_DISK_MAX_BYTES, - TILE_DISK_FILE_OVERHEAD_BYTES, - TILE_MAX_BYTES, - TILE_MEMORY_BUDGET_BYTES, - TILE_MEMORY_ENTRY_OVERHEAD_BYTES, - TILE_FETCH_TIMEOUT_MS, - TILE_UPSTREAM_CONCURRENCY, - TILE_KEY_REJECTED_HOLD_MS, - TILE_RATE_LIMIT_HOLD_MS, - TILE_RATE_LIMIT_MAX_HOLD_MS, - mapillaryToken, -} from './constants.js'; -/** Log without the stack; the token is never in scope here. */ -function warn(what, error) { - console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error); +/** Mapillary vector tiles, /{layer}/2/{z}/{x}/{y}: the only host the token goes to. */ +const TILE_HOST = 'https://tiles.mapillary.com/maps/vtp'; +/** Only the street zooms the app draws are proxied. */ +const MIN_ZOOM = 11; +const MAX_ZOOM = 14; +/** + * The z14 `image` layer is ~98% of a tile (12 MB over a dense city) and unused + * (positions come from the Graph API), so it is dropped in transit. + */ +const DROP_LAYERS = ['image']; +/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */ +export const TILE_MAX_BYTES = 48 * 1024 * 1024; +const FETCH_TIMEOUT_MS = 60_000; +/** Upstream fetches at once; more wait their turn. */ +export const TILE_UPSTREAM_CONCURRENCY = 6; +/** Tiles change only when new imagery is processed. */ +export const TILE_TTL_MS = 24 * 60 * 60 * 1000; +/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */ +export const TILE_MEMORY_MAX_TILES = 120; +/** A trimmed tile is ~80 KB; a body this big failed to trim and is not kept. */ +export const TILE_MEMORY_MAX_TILE_BYTES = 4 * 1024 * 1024; +/** The longest upstream Retry-After honoured, in seconds. */ +export const MAX_RETRY_AFTER_SEC = 600; + +/** The client token lives in the browser by design; the server adds it to tile URLs too. */ +export function mapillaryToken() { + return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim(); } -/** Thrown for a request this proxy refuses before contacting Mapillary. */ -export class TileRequestError extends Error { - constructor(message, status = 400) { - super(message); - this.name = 'TileRequestError'; - this.status = status; - } -} - -/** Mapillary answered with an error status; 401/403 set `keyRejected`. */ +/** Mapillary answered with an error status (or a size this proxy refuses). */ export class TileUpstreamError extends Error { - constructor(status, message, { retryAfterSec = null } = {}) { + constructor(status, message, retryAfter = null) { super(message || `Mapillary tiles HTTP ${status}`); this.name = 'TileUpstreamError'; this.status = status; - this.keyRejected = status === 401 || status === 403; - this.retryAfterSec = retryAfterSec; + /** The upstream Retry-After header, as sent. */ + this.retryAfter = retryAfter; } } -/** - * A rejected token or rate limit that answers every tile miss until it expires - * (or the token changes). Cached tiles are still served meanwhile. - * @type {{status: number, until: number, token: string}|null} - */ -let _upstreamHold = null; - -/** Milliseconds to wait from a Retry-After header (seconds or an HTTP date). */ -function retryAfterMs(header, now = Date.now()) { +/** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */ +function retryAfterSeconds(header, now = Date.now()) { const text = String(header ?? '').trim(); - let ms = NaN; - if (/^\d+$/.test(text)) ms = Number(text) * 1000; - else if (text) ms = Date.parse(text) - now; - if (!Number.isFinite(ms) || ms <= 0) return TILE_RATE_LIMIT_HOLD_MS; - return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS); + if (!text) return null; + const seconds = /^\d+$/.test(text) + ? Number(text) + : (Date.parse(text) - now) / 1000; + return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : null; } -function heldRefusal(token) { - const hold = _upstreamHold; - if (!hold) return null; - const left = hold.until - Date.now(); - if (left <= 0 || hold.token !== token) { - _upstreamHold = null; +/** The wait a 429 asks for, bounded; a missing or malformed header means a minute. */ +export function retryAfterWait(header, now = Date.now()) { + return Math.min(retryAfterSeconds(header, now) ?? 60, MAX_RETRY_AFTER_SEC); +} + +/** + * While Mapillary rate-limits, tile misses are refused here until the wait is + * over, so the rest of a view (and the fetches queued behind the first 429) + * do not go on asking. Scoped to the token that was refused. + * @type {{until: number, token: string}|null} + */ +let refusal = null; + +/** The refusal still in force for `token`, as the error to answer with. */ +function refusedNow(token) { + if (!refusal) return null; + const left = refusal.until - Date.now(); + if (left <= 0 || refusal.token !== token) { + refusal = null; return null; } - return new TileUpstreamError(hold.status, undefined, { - retryAfterSec: Math.ceil(left / 1000), - }); + return new TileUpstreamError(429, undefined, String(Math.ceil(left / 1000))); } /** - * Validate a tile address against the layer's zoom ranges. - * @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}} + * Parse `/coverage/{z}/{x}/{y}`; null unless z is 11–14 and x/y are on the grid. + * @returns {{z: number, x: number, y: number, key: string}|null} */ -export function normalizeTileAddress({ layer, z, x, y }) { - const spec = Object.hasOwn(TILE_LAYERS, layer) ? TILE_LAYERS[layer] : null; - if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`); - const zi = Number(z); - const xi = Number(x); - const yi = Number(y); - if ( - ![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) || - !spec.zoomRanges.some(([min, max]) => zi >= min && zi <= max) - ) - throw new TileRequestError( - `Tile zoom for ${layer} must be ${spec.zoomRanges - .map(([min, max]) => `${min}–${max}`) - .join(' or ')}`, - ); - const n = 2 ** zi; - if (xi >= n || yi >= n) - throw new TileRequestError('Tile address out of range'); - return { - layer, - upstream: spec.upstream, - dropLayers: spec.dropLayers || [], - z: zi, - x: xi, - y: yi, - key: `${layer}/${zi}/${xi}/${yi}`, - }; +export function parseTilePath(pathname) { + const match = /^\/coverage\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(pathname); + if (!match) return null; + const [z, x, y] = match.slice(1).map(Number); + if (z < MIN_ZOOM || z > MAX_ZOOM || x >= 2 ** z || y >= 2 ** z) return null; + return { z, x, y, key: `${z}/${x}/${y}` }; } -/** @type {Map} insertion-ordered LRU */ -const _memory = new Map(); -let _memoryBytes = 0; -const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES; -/** - * Upstream fetches shared by every request for the same tile; the last waiter - * to leave cancels it (see `joinFlight`). - * @type {Map, waiters: number}>} - */ -const _inFlight = new Map(); - -function memoryGet(key) { - const hit = _memory.get(key); - if (!hit) return null; - // Same life as the disk copy, so a long-running server never serves stale - // coverage. - if (Date.now() - hit.at > TILE_DISK_TTL_MS) { - _memory.delete(key); - _memoryBytes -= memoryCost(hit.bytes); - return null; - } - _memory.delete(key); - _memory.set(key, hit); - return hit.bytes; -} - -/** `at` is when Mapillary served the bytes (a disk tile's mtime). */ -function memoryPut(key, bytes, at = Date.now()) { - if (memoryCost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return; - const existing = _memory.get(key); - if (existing) _memoryBytes -= memoryCost(existing.bytes); - _memory.set(key, { bytes, at }); - _memoryBytes += memoryCost(bytes); - while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) { - const [oldest, entry] = _memory.entries().next().value; - _memory.delete(oldest); - _memoryBytes -= memoryCost(entry.bytes); - } -} - -/** Disk cache root; tests point it at a temporary directory. */ -let _diskDir = TILE_DISK_DIR; - -function diskPath({ layer, z, x, y }, root = _diskDir) { - return path.join(root, layer, String(z), `${x}-${y}.pbf`); -} - -async function readDisk(address) { - const file = diskPath(address); +function trim(bytes) { try { - const stat = await fsp.stat(file); - if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null; - return { bytes: await fsp.readFile(file), at: stat.mtimeMs }; - } catch { - return null; - } -} - -/** Unsettled background writes and sweeps; requests never wait on them. */ -const _background = new Set(); - -function track(task) { - const settled = task - .catch(() => {}) - .finally(() => _background.delete(settled)); - _background.add(settled); - return settled; -} - -/** @type {Map} cache file to its newest write's sequence */ -const _diskWrites = new Map(); -let _diskWriteSeq = 0; - -function diskWritePending(address) { - return _diskWrites.has(diskPath(address)); -} - -/** - * Write a tile in the background. `at` keeps a rewrite's original fetch time - * so it does not extend the tile's life. An overtaken write steps aside. - */ -function writeDisk(address, bytes, at = null) { - // Sweep this write's root even if the cache dir has moved since. - const root = _diskDir; - const file = diskPath(address, root); - const seq = ++_diskWriteSeq; - const tmp = `${file}.${process.pid}-${seq}.tmp`; - const newest = () => _diskWrites.get(file) === seq; - _diskWrites.set(file, seq); - return track( - fsp - .mkdir(path.dirname(file), { recursive: true }) - .then(() => fsp.writeFile(tmp, bytes)) - .then(() => at != null && fsp.utimes(tmp, new Date(), new Date(at))) - .then(() => { - if (!newest()) return fsp.rm(tmp, { force: true }); - return fsp.rename(tmp, file).then(() => scheduleSweep(root)); - }) - .catch(async (error) => { - await fsp.rm(tmp, { force: true }).catch(() => {}); - warn('tile cache write failed', error); - }) - .finally(() => { - if (newest()) _diskWrites.delete(file); - }), - ); -} - -let _lastSweepAt = 0; -let _sweeping = null; -let _sweepWarned = false; - -function scheduleSweep(root) { - if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS) - return; - _lastSweepAt = Date.now(); - const sweep = sweepTileDisk({ root }) - .catch((error) => { - if (_sweepWarned) return; - _sweepWarned = true; - warn('tile cache sweep failed', error); - }) - .finally(() => { - if (_sweeping === tracked) _sweeping = null; - }); - const tracked = track(sweep); - _sweeping = tracked; -} - -/** Every file under `dir` with size and mtime; a missing dir is empty. */ -async function listCacheFiles(dir) { - let entries; - try { - entries = await fsp.readdir(dir, { withFileTypes: true }); + return stripTileLayers(bytes, DROP_LAYERS); } catch (error) { - if (error?.code === 'ENOENT') return []; - throw error; - } - const files = []; - for (const entry of entries) { - const file = path.join(dir, entry.name); - if (entry.isDirectory()) files.push(...(await listCacheFiles(file))); - else if (entry.isFile()) { - const stat = await fsp.stat(file).catch(() => null); - if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs }); - } - } - return files; -} - -/** - * Delete expired tiles, then the oldest until the rest fit in `maxBytes`. - * @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept - */ -export async function sweepTileDisk({ - root = _diskDir, - maxBytes = TILE_DISK_MAX_BYTES, -} = {}) { - const now = Date.now(); - const files = (await listCacheFiles(root)).sort((a, b) => a.at - b.at); - const cost = (size) => size + TILE_DISK_FILE_OVERHEAD_BYTES; - let bytes = files.reduce((sum, { size }) => sum + cost(size), 0); - let removed = 0; - for (const { file, size, at } of files) { - if (now - at <= TILE_DISK_TTL_MS && bytes <= maxBytes) break; - await fsp.rm(file, { force: true }); - bytes -= cost(size); - removed++; - } - return { removed, bytes }; -} - -/** The one origin the tile token may be sent to, at every redirect hop. */ -const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin; -export const TILE_MAX_REDIRECTS = 3; -const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); - -/** - * Follow redirects by hand so every hop stays HTTPS on the tile origin and the - * token in the query never reaches another host. A bad redirect is a 502. - */ -async function fetchPinned(url, init) { - let next = url; - for (let hop = 0; ; hop++) { - const response = await fetch(next, { ...init, redirect: 'manual' }); - if (!REDIRECT_STATUSES.has(response.status)) return response; - await response.body?.cancel().catch(() => {}); - if (hop >= TILE_MAX_REDIRECTS) - throw new TileUpstreamError(502, 'Mapillary tile redirected too often'); - let target = null; - try { - target = new URL(response.headers.get('location') ?? '', next); - } catch { - /* unparseable Location */ - } - if ( - !response.headers.get('location') || - target?.protocol !== 'https:' || - target.origin !== TILE_ORIGIN || - target.username || - target.password - ) - throw new TileUpstreamError( - 502, - 'Mapillary tile redirect left the tile origin', - ); - next = target.href; - } -} - -async function fetchUpstream(address, signal) { - const token = mapillaryToken(); - if (!token) throw new TileRequestError('Mapillary token not configured', 503); - const held = heldRefusal(token); - if (held) throw held; - const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`; - const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS); - const oversize = new AbortController(); - const response = await fetchPinned(url, { - signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)), - headers: { Accept: 'application/x-protobuf' }, - }); - if (response.status === 404 || response.status === 204) { - await response.body?.cancel().catch(() => {}); - return Buffer.alloc(0); - } - if (!response.ok) { - await response.body?.cancel().catch(() => {}); - const error = new TileUpstreamError(response.status); - if (error.keyRejected) - _upstreamHold = { - status: response.status, - until: Date.now() + TILE_KEY_REJECTED_HOLD_MS, - token, - }; - else if (response.status === 429) { - const wait = retryAfterMs(response.headers.get('retry-after')); - _upstreamHold = { status: 429, until: Date.now() + wait, token }; - error.retryAfterSec = Math.ceil(wait / 1000); - } - throw error; - } - // Chunked or compressed bodies have no Content-Length: the cap applies as - // the body streams, and an oversized one also aborts the request. - try { - const bytes = await readResponseBytesCapped( - response, - TILE_MAX_BYTES, - signal, - ); - return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); - } catch (error) { - if (error?.code !== 'RESPONSE_TOO_LARGE') throw error; - oversize.abort(); - throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap'); - } -} - -/** @type {{active: number, queue: Array<() => void>}} upstream slots */ -let _slots = { active: 0, queue: [] }; - -/** - * Resolve with a release function once a slot is free. An abort while - * queued leaves the queue. - * @returns {Promise<() => void>} - */ -function acquireUpstreamSlot(signal) { - const slots = _slots; - return new Promise((resolve, reject) => { - if (signal.aborted) return reject(signal.reason); - const onAbort = () => { - const index = slots.queue.indexOf(grant); - if (index !== -1) slots.queue.splice(index, 1); - reject(signal.reason); - }; - function grant() { - signal.removeEventListener('abort', onAbort); - slots.active++; - let released = false; - resolve(() => { - if (released) return; - released = true; - slots.active--; - slots.queue.shift()?.(); - }); - } - if (slots.active < TILE_UPSTREAM_CONCURRENCY) return grant(); - signal.addEventListener('abort', onAbort, { once: true }); - slots.queue.push(grant); - }); -} - -function trim(address, bytes) { - if (!address.dropLayers.length || !bytes.length) return bytes; - try { - return stripTileLayers(bytes, address.dropLayers); - } catch (error) { - warn('tile trim failed, serving raw', error); + console.warn('[Mapillary Proxy] tile trim failed:', error?.message); return bytes; } } -/** - * Fetch one tile from memory, disk, a shared flight, then Mapillary. Empty - * bytes mean no coverage. - * @param {{layer:string,z:number|string,x:number|string,y:number|string}} request - * @param {{signal?: AbortSignal}} [options] - * @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>} - */ -export async function fetchTile(request, { signal } = {}) { - const address = normalizeTileAddress(request); - const memory = memoryGet(address.key); - if (memory) return { bytes: memory, source: 'memory', address }; - const disk = await readDisk(address); - if (disk) { - // Older cache files may be untrimmed: rewrite once, keeping the fetch time. - const bytes = trim(address, disk.bytes); - if (bytes !== disk.bytes && !diskWritePending(address)) - writeDisk(address, bytes, disk.at); - memoryPut(address.key, bytes, disk.at); - return { bytes, source: 'disk', address }; +/** One tile from Mapillary, trimmed; empty bytes mean no coverage. */ +async function fetchUpstream({ z, x, y }, signal) { + const token = mapillaryToken(); + const refused = refusedNow(token); + if (refused) throw refused; + const response = await fetch( + `${TILE_HOST}/mly1_public/2/${z}/${x}/${y}?access_token=${encodeURIComponent(token)}`, + { + signal: AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]), + // The token is in the query: a redirect is never followed (a 3xx is a 502). + redirect: 'manual', + headers: { Accept: 'application/x-protobuf' }, + }, + ); + if (!response.ok || response.status === 204) { + await response.body?.cancel().catch(() => {}); + if (response.status === 204 || response.status === 404) + return Buffer.alloc(0); + const retryAfter = response.headers.get('retry-after'); + if (response.status === 429) + refusal = { + token, + until: Date.now() + retryAfterWait(retryAfter) * 1000, + }; + throw new TileUpstreamError(response.status, undefined, retryAfter); + } + try { + const bytes = await readResponseBytesCapped(response, TILE_MAX_BYTES); + return trim(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length)); + } catch (error) { + if (error?.code !== 'RESPONSE_TOO_LARGE') throw error; + throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap'); } - // A caller gone while the disk was read must not start a fetch nobody joins. - signal?.throwIfAborted(); - let flight = _inFlight.get(address.key); - const joined = Boolean(flight) && !flight.controller.signal.aborted; - if (!joined) flight = startFlight(address); - const bytes = await joinFlight(flight, signal); - return { bytes, source: joined ? 'inflight' : 'upstream', address }; } -/** Start the shared upstream fetch; it holds a slot until the body is read. */ +let upstreamActive = 0; +/** @type {Array<() => void>} fetches waiting for a slot, oldest first */ +const upstreamWaiting = []; + +/** + * Run `task` in an upstream slot, waiting for one unless `signal` aborts + * first. A freed slot passes straight to the oldest waiter, still counted, so + * a request arriving meanwhile cannot take it first. + */ +async function withUpstreamSlot(signal, task) { + if (upstreamActive < TILE_UPSTREAM_CONCURRENCY) upstreamActive++; + else + await new Promise((resolve, reject) => { + const wake = () => { + signal.removeEventListener('abort', leave); + resolve(); + }; + const leave = () => { + const at = upstreamWaiting.indexOf(wake); + if (at !== -1) upstreamWaiting.splice(at, 1); + reject(signal.reason); + }; + upstreamWaiting.push(wake); + signal.addEventListener('abort', leave, { once: true }); + }); + try { + // Handed a slot after leaving: it goes on to the next waiter. + signal.throwIfAborted(); + return await task(); + } finally { + const next = upstreamWaiting.shift(); + if (next) next(); + else upstreamActive--; + } +} + +/** `promise`, or a rejection as soon as `signal` aborts. */ +function unlessAborted(promise, signal) { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const leave = () => reject(signal.reason); + signal.addEventListener('abort', leave, { once: true }); + promise + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', leave)); + }); +} + +/** @type {Map} least recently used first */ +const memory = new Map(); +/** + * Upstream fetches shared per tile, with the requests still waiting on each. + * @type {Map, controller: AbortController, waiters: number}>} + */ +const inFlight = new Map(); + +function remember(key, bytes, at = Date.now()) { + if (bytes.length > TILE_MEMORY_MAX_TILE_BYTES) return; + memory.delete(key); + memory.set(key, { bytes, at }); + for (const oldest of memory.keys()) { + if (memory.size <= TILE_MEMORY_MAX_TILES) break; + memory.delete(oldest); + } +} + +function recall(key) { + const hit = memory.get(key); + if (!hit) return null; + if (Date.now() - hit.at > TILE_TTL_MS) { + memory.delete(key); + return null; + } + remember(key, hit.bytes, hit.at); // most recently used again + return hit.bytes; +} + +/** Start the upstream fetch for a tile, shared until its last request leaves. */ function startFlight(address) { const controller = new AbortController(); - const flight = { controller, waiters: 0, promise: null }; - flight.promise = acquireUpstreamSlot(controller.signal) - .then((release) => - fetchUpstream(address, controller.signal).finally(release), - ) - .then((raw) => trim(address, raw)) + const flight = { controller, waiters: 0 }; + flight.promise = withUpstreamSlot(controller.signal, () => + fetchUpstream(address, controller.signal), + ) .then((bytes) => { - memoryPut(address.key, bytes); - writeDisk(address, bytes); + remember(address.key, bytes); return bytes; }) .finally(() => { - if (_inFlight.get(address.key) === flight) _inFlight.delete(address.key); + if (inFlight.get(address.key) === flight) inFlight.delete(address.key); }); - // Every waiter may have left before it settles; nobody awaits it then. + // Every request may have left; the rejection is then nobody's. flight.promise.catch(() => {}); - _inFlight.set(address.key, flight); + inFlight.set(address.key, flight); return flight; } /** - * Wait on a shared flight. A caller's abort rejects only that caller; the - * fetch is cancelled when the last waiter leaves. + * One tile from memory, a shared in-flight fetch, or Mapillary. `signal` + * aborts when the request's client leaves; the fetch stops once all have. + * @param {{z: number, x: number, y: number, key: string}} address from parseTilePath + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{bytes: Buffer, source: 'memory'|'inflight'|'upstream'}>} */ -function joinFlight(flight, signal) { - signal?.throwIfAborted(); +export async function fetchTile(address, { signal } = {}) { + const cached = recall(address.key); + if (cached) return { bytes: cached, source: 'memory' }; + let flight = inFlight.get(address.key); + const shared = Boolean(flight); + flight ||= startFlight(address); flight.waiters++; - return new Promise((resolve, reject) => { - let done = false; - const leave = () => { - if (done) return false; - done = true; - signal?.removeEventListener('abort', onAbort); - flight.waiters--; - return true; - }; - const onAbort = () => { - if (!leave()) return; - if (flight.waiters === 0) flight.controller.abort(); - reject(signal.reason); - }; - signal?.addEventListener('abort', onAbort, { once: true }); - flight.promise.then( - (bytes) => leave() && resolve(bytes), - (error) => leave() && reject(error), - ); - }); + try { + const bytes = await unlessAborted(flight.promise, signal); + return { bytes, source: shared ? 'inflight' : 'upstream' }; + } finally { + if (--flight.waiters === 0) { + // A later request for the tile must not join a cancelled fetch. + if (inFlight.get(address.key) === flight) inFlight.delete(address.key); + flight.controller.abort(); + } + } } -/** Test seam: forget every cached tile held in memory. */ -export function _resetTileMemoryForTest() { - _memory.clear(); - _memoryBytes = 0; - _inFlight.clear(); - _upstreamHold = null; - _slots = { active: 0, queue: [] }; -} - -/** Test seam: how many tiles memory holds and the bytes charged for them. */ -export function _tileMemoryForTest() { - return { entries: _memory.size, bytes: _memoryBytes }; -} - -/** Test seam: wait for every background write and the sweeps they start. */ -export async function _settleTileWritesForTest() { - while (_background.size) await Promise.allSettled([..._background]); -} - -/** Test seam: keep the disk cache in `dir` (null restores the default). */ -export function _setTileCacheDirForTest(dir) { - _diskDir = dir || TILE_DISK_DIR; - _lastSweepAt = 0; +/** Test seam: forget every cached and in-flight tile. */ +export function _resetTileCacheForTest() { + memory.clear(); + inFlight.clear(); + refusal = null; + upstreamActive = 0; + upstreamWaiting.length = 0; } diff --git a/server/providers/mapillary/trim.js b/server/providers/mapillary/trim.js index 90824769e..65f190172 100644 --- a/server/providers/mapillary/trim.js +++ b/server/providers/mapillary/trim.js @@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) { }, null); return dropped ? Buffer.from(writer.finish()) : bytes; } - -/** Names of the layers inside a vector tile, in order. */ -export function listTileLayers(bytes) { - const names = []; - if (!bytes?.length) return names; - new PbfReader(bytes).readFields((tag, _result, pbf) => { - if (tag !== TILE_LAYER_FIELD) return; - const end = pbf.readVarint() + pbf.pos; - names.push(layerName(bytes.subarray(pbf.pos, end))); - pbf.pos = end; - }, null); - return names; -} diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 08ad87d9f..7d252b8ed 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -9,7 +9,10 @@ import { fetchOverpassPayload, overpassNotConfigured, } from './overpass/transport.js'; -import { resolveOverpassUpstreams } from './overpass/constants.js'; +import { + resolveOverpassUpstreams, + resolveOverpassUpstreamTag, +} from './overpass/constants.js'; import { _militaryInstallationCache, trimMilitaryInstallationCache, @@ -74,7 +77,13 @@ function militaryInstallationsProxy() { retrievedAt: new Date().toISOString(), status: 'ready', }; - const entry = { payload, cachedAt: Date.now() }; + // Tagged with the configured upstream set so the disk layer does not serve + // this answer back after the operator repoints (see overpass/cache.js). + const entry = { + payload, + cachedAt: Date.now(), + upstreams: resolveOverpassUpstreamTag(), + }; _militaryInstallationCache.set(key, entry); trimMilitaryInstallationCache(); writeMilitaryInstallationDisk(key, entry); diff --git a/server/providers/military-installations/cache.js b/server/providers/military-installations/cache.js index c77627e29..4d77981e6 100644 --- a/server/providers/military-installations/cache.js +++ b/server/providers/military-installations/cache.js @@ -5,6 +5,7 @@ import { MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, } from './constants.js'; +import { cachedByConfiguredUpstream } from '../overpass/constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { promises as fsp } from 'node:fs'; @@ -114,6 +115,9 @@ async function readMilitaryInstallationDisk( const entry = JSON.parse( await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'), ); + // Same reason as the Overpass disk cache: the key is the bbox alone, so an + // entry written against a different OVERPASS_UPSTREAMS is otherwise a hit. + if (!cachedByConfiguredUpstream(entry)) return null; if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null; return migrateMilitaryInstallationEntry(entry); } catch { diff --git a/server/providers/openai/codex-auth.js b/server/providers/openai/codex-auth.js index 5f16ee824..4d3f992da 100644 --- a/server/providers/openai/codex-auth.js +++ b/server/providers/openai/codex-auth.js @@ -145,7 +145,7 @@ const CODEX_LOGIN_ENV = /^(?:PATH|PATHEXT|HOME|USER|USERNAME|LOGNAME|SHELL|LANG|LANGUAGE|LC_[A-Z_]+|TERM|TZ|TMPDIR|TEMP|TMP|XDG_[A-Z_]+|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|BROWSER|WSL_[A-Z_]+|WSLENV|(?:HTTPS?|ALL|NO)_PROXY|SSL_CERT_(?:FILE|DIR)|NODE_EXTRA_CA_CERTS|SYSTEMROOT|WINDIR|COMSPEC|USERPROFILE|APPDATA|LOCALAPPDATA|PROGRAMDATA|HOMEDRIVE|HOMEPATH|CODEX_[A-Z_]+)$/i; /** The environment `codex login` runs with: the allowlist, then `CODEX_HOME`. */ -export function codexLoginEnvironment(environment, codexHome) { +function codexLoginEnvironment(environment, codexHome) { const env = {}; for (const [name, value] of Object.entries(environment || {})) if (CODEX_LOGIN_ENV.test(name) && value !== undefined) env[name] = value; diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index 956569642..c8151d2f6 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -157,4 +157,4 @@ function createDebugLogHandler({ }; } -export { createDebugLogHandler, omitVoiceContent }; +export { createDebugLogHandler }; diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 7d0e35636..c9c4a43b4 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -10,20 +10,20 @@ function realtimeInstructions( 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', 'Only control the app by calling the provided tools. Never invent tool names or arguments.', 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', - 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'Panels: set_panel_open or show_data_layers_menu; "Open Context" is only set_panel_open{panelId:"global-context-panel",open:true}; "Open Contacts" is set_context_mode contacts (expands Context itself). Unnamed settings: ask "Voice settings or Provider settings?" first; named panels apply directly. Contacts, Space Missions and Cockpit need explicit naming; opening Context or selecting/viewing an aircraft never enters them. In Cockpit, next/previous uses control_cockpit; track_entity/fly_to_location are refused: never retry, exit Cockpit first.', 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking stops the tracking.', '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").', + 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', '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, never 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 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, never 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.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', 'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.', 'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.', - '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.', 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 450c9da81..23d5da9de 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -44,7 +44,7 @@ export const ACTION_DESCRIPTIONS = { }, select_nearest_aircraft: { description: - 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result.', $position: 1, parameters: { properties: { @@ -111,12 +111,18 @@ export const ACTION_DESCRIPTIONS = { }, }, set_panel_open: { - description: 'Open or close a GEV UI panel/dropdown.', + description: + 'Visual Presets/styles/filters=control-panel; Scenes/saved scenes=scene-panel; Voice Settings=voice-settings; voice control/Preview=voice-preview; Provider Settings/API keys=provider-settings. Voice panels exit Cockpit directly.', + $position: 1, + }, + set_voice_inactivity_timeout: { + description: + 'Set the requested exact inactivity duration directly: integer minutes 1–60, 0=None. Report applied/persisted truthfully; an unsaved live change is not saved.', $position: 1, }, set_context_mode: { description: - 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + 'Enter/exit Contacts or Space Missions only when explicitly requested; opening Context alone uses set_panel_open.', $position: 1, parameters: { properties: { @@ -129,23 +135,23 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { action: { description: - 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + 'previous/next (or prev) cycles nearby Cockpit contacts.', $position: 2, }, targetLayer: { description: - 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + 'Optional next/previous layer filter (e.g. military-only).', $position: 2, }, aircraftClass: { description: - 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + 'Optional next/previous aircraft class filter (e.g. helicopter).', $position: 1, }, }, @@ -179,7 +185,7 @@ export const ACTION_DESCRIPTIONS = { }, get_current_view_state: { description: - 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.', + 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action. Tracked aircraft emergency status is a crowd-sourced transponder broadcast, not a confirmed incident: quote emergencyBroadcast and never infer a hijack or an incident ending from a code change.', $position: 1, }, set_hud: { @@ -511,18 +517,18 @@ export const ACTION_DESCRIPTIONS = { }, move_camera: { description: - 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', $position: 1, parameters: { properties: { direction: { description: - 'Required except for orbit (defaults right/clockwise) and stop.', + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', $position: 2, }, mode: { description: - 'once = bounded eased nudge (default); continuous = until stop/manual input.', + 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', $position: 2, }, }, @@ -543,7 +549,7 @@ export const ACTION_DESCRIPTIONS = { }, analyst_query: { description: - 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values).', + 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.', $position: 1, parameters: { properties: { diff --git a/server/providers/overpass.js b/server/providers/overpass.js index e6631e889..bf6004ac6 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -1,5 +1,6 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { readRequestBodyCapped } from './common/request.js'; +import { admitSameSite } from './common/same-site.js'; import { OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT, @@ -27,6 +28,14 @@ const _overpassInFlight = new Map(); let _overpassConcurrent = 0; +// Bound body-reading and validation work, including malformed requests and +// cache hits. Keep the tighter upstream budget below independent of this gate. +const _overpassRequestLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 300, + globalMax: 900, +}); + const _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, @@ -94,6 +103,17 @@ function overpassProxy({ routing = {} } = {}) { return; } + if (admitSameSite(req, res)) return; + if (!_overpassRequestLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '60', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + // Collect POST body with a hard byte cap (Overpass QL queries are small) let body; try { @@ -144,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) { readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), allowUpstream: () => _overpassRateLimiter(clientKey(req)), + loadUpstream: async () => { + if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) + return { + status: 503, + contentType: 'application/json', + retryAfterMs: 2000, + body: JSON.stringify({ + error: 'Overpass proxy busy — try again shortly', + }), + }; + _overpassConcurrent++; + try { + const payload = await fetchOverpassPayload(safeBody); + if (overpassPayloadIsData(payload)) { + const entry = { ...payload, cachedAt: Date.now() }; + _overpassCache.set(cacheKey, entry); + trimOverpassCache(); + writeOverpassDisk(cacheKey, entry); + } + return payload; + } finally { + _overpassConcurrent--; + } + }, }); if (preflight.source === 'RATE_LIMITED') { res.writeHead(429, { @@ -153,71 +197,20 @@ function overpassProxy({ routing = {} } = {}) { res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; } - if (preflight.source !== 'UPSTREAM') { - // A coalesced caller sees the same failure as the original request - // and must get the same last-good fallback, not the raw refusal. - if (!overpassPayloadIsData(preflight.payload)) { - const stale = await readStaleOverpass(cacheKey); - if (stale) { - sendOverpassResponse(res, stale, 'STALE'); - return; - } - } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } - sendOverpassResponse(res, preflight.payload, preflight.source); - return; - } - - // From here onward the request is genuinely upstream-bound and has - // consumed one local limiter slot. Cache and dedupe hits above do not. - if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) { - res.writeHead(503, { - 'Content-Type': 'application/json', - 'Retry-After': '2', - }); - res.end( - JSON.stringify({ - error: 'Overpass proxy busy — try again shortly', - }), - ); - return; - } - _overpassConcurrent += 1; - const requestPromise = fetchOverpassPayload(safeBody) - .then((payload) => { - // Only a 2xx is data. `< 500` cached every 4xx, so one mirror's - // refusal was written to memory AND disk — and boundary-class - // queries hold a month-long TTL, so a single 406 outlived the - // outage that caused it. - if (overpassPayloadIsData(payload)) { - const entry = { ...payload, cachedAt: Date.now() }; - _overpassCache.set(cacheKey, entry); - trimOverpassCache(); - writeOverpassDisk(cacheKey, entry); - } - return payload; - }) - .finally(() => { - _overpassConcurrent -= 1; - _overpassInFlight.delete(cacheKey); - }); - - _overpassInFlight.set(cacheKey, requestPromise); - const payload = await requestPromise; - // Degraded upstream (rate-limited on every mirror / 5xx / runtime - // error): last-good roads beat an empty layer — serve stale from - // memory or disk at ANY age before surfacing the failure. - if (!overpassPayloadIsData(payload)) { + if (!overpassPayloadIsData(preflight.payload)) { const stale = await readStaleOverpass(cacheKey); if (stale) { sendOverpassResponse(res, stale, 'STALE'); return; } } - sendOverpassResponse(res, payload, 'MISS'); + // Preflight already published the disk hit to memory; only trim. + if (preflight.source === 'DISK') trimOverpassCache(); + sendOverpassResponse( + res, + preflight.payload, + preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source, + ); } catch (e) { // Every mirror threw (network-level). Same serve-stale rule. const stale = cacheKey ? await readStaleOverpass(cacheKey) : null; diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index b4e53ab21..b1a09908a 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -1,3 +1,4 @@ +import { coalesceProxyRequest } from '../common/http.js'; import { isOverpassBoundaryQuery } from './query.js'; import { OVERPASS_BOUNDARY_DISK_TTL_MS, @@ -5,6 +6,7 @@ import { OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES, + cachedByConfiguredUpstream, } from './constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; @@ -47,6 +49,11 @@ async function readOverpassDisk(cacheKey, maxAgeMs) { // Older versions persisted 4xx refusals with normal data TTLs. Ignore // them on both fresh and stale reads so an upgrade can recover immediately. if (!overpassPayloadIsData(payload)) return null; + // The key is the query alone, so a file written by a different upstream — + // including the public instances that were the default before #742 — is a + // hit for the same query. Refuse it: the operator's configured instance + // must be what answers, on the stale path too. + if (!cachedByConfiguredUpstream(payload)) return null; if (Date.now() - payload.cachedAt > maxAgeMs) return null; return payload; } catch { @@ -72,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) { /** * Resolve every cache/coalescing layer before admitting a request to the local * upstream rate limiter. The injected limiter callback is invoked exactly once - * for a complete cache miss and never for memory, in-flight, or disk hits. + * for a complete cache miss and never for memory, in-flight, or disk hits. A + * caller that joined a reservation rejected by another client's limiter + * retries with its own limiter instead of inheriting that 429. * Exported so the admission ordering can be tested without a Vite server. * * @param {object} options @@ -81,6 +90,7 @@ function writeOverpassDisk(cacheKey, payload) { * @param {Map>} options.inFlight * @param {()=>Promise} options.readDisk * @param {()=>boolean} options.allowUpstream + * @param {()=>Promise} options.loadUpstream Fetch and publish the admitted result. * @param {number} [options.now] * @param {number} [options.cacheMs] * @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>} @@ -91,22 +101,34 @@ async function resolveOverpassPreflight({ inFlight, readDisk, allowUpstream, + loadUpstream = async () => null, now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { - const cached = memoryCache.get(cacheKey); - if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) - return { source: 'HIT', payload: cached }; + for (;;) { + const cached = memoryCache.get(cacheKey); + if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) + return { source: 'HIT', payload: cached }; - const pending = inFlight.get(cacheKey); - if (pending) return { source: 'INFLIGHT', payload: await pending }; - - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk }; - - return allowUpstream() - ? { source: 'UPSTREAM', payload: null } - : { source: 'RATE_LIMITED', payload: null }; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through its own upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + if (!request.shared) return result; + // The owner's quota rejection belongs to the owner's client. The rejected + // reservation is released before joiners resume, so retry and let this + // caller own the next reservation (or join an admitted one) on its quota. + if (result.source === 'RATE_LIMITED') continue; + return { ...result, source: 'INFLIGHT' }; + } } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index 40f244d11..6cfd7257a 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { createHash } from 'node:crypto'; // --------------------------------------------------------------------------- // Overpass API proxy constants and cache state @@ -27,16 +28,68 @@ function parseOverpassUpstreams(raw) { return endpoints.slice(0, 8); } -let upstreamMemo = { raw: null, endpoints: [] }; +let upstreamMemo = { raw: null, endpoints: [], tag: 'none' }; + +/** + * Short, stable identifier for the configured upstream set. Cached responses + * carry it so an answer produced by a different instance is not served after + * the operator repoints `OVERPASS_UPSTREAMS` — the disk cache outlives the + * configuration change by 7 days, a month for boundary queries, and forever on + * the serve-stale path. It is a hash, never the URLs, and it is only ever + * compared on read: it is not sent to clients and does not appear in errors. + */ +function upstreamTag(endpoints) { + if (!endpoints.length) return 'none'; + // Sorted, so reordering the same instances for failover keeps the cache. + return createHash('sha256') + .update([...endpoints].sort().join('\n')) + .digest('hex'); +} /** Resolve after environment loading. Public Overpass instances are not used by default. */ function resolveOverpassUpstreams() { - const raw = process.env.OVERPASS_UPSTREAMS || ''; - if (upstreamMemo.raw !== raw) - upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) }; + readUpstreamMemo(); return [...upstreamMemo.endpoints]; } +/** The configured-upstream tag stored alongside, and checked against, cache entries. */ +function resolveOverpassUpstreamTag() { + return readUpstreamMemo().tag; +} + +let reportedForeignCache = false; + +/** + * Was this cached entry produced by the upstream set configured now? + * + * With nothing configured there is no instance to contradict, and serving + * last-good data at any age is the point of the cache (the proxy's + * not-configured path depends on it), so any entry is accepted. Once an + * operator does configure an instance, only its own answers are served: an + * entry with no tag, or one from a different instance, is refused rather than + * matched on the query alone. Shared by both caches fed by these upstreams. + */ +function cachedByConfiguredUpstream(entry) { + const tag = resolveOverpassUpstreamTag(); + if (tag === 'none' || entry?.upstreams === tag) return true; + if (!reportedForeignCache) { + reportedForeignCache = true; + console.warn( + '[Overpass] ignoring cached responses produced by a different OVERPASS_UPSTREAMS; they will be refetched from the configured instance.', + ); + } + return false; +} + +function readUpstreamMemo() { + const raw = process.env.OVERPASS_UPSTREAMS || ''; + if (upstreamMemo.raw !== raw) { + const endpoints = parseOverpassUpstreams(raw); + upstreamMemo = { raw, endpoints, tag: upstreamTag(endpoints) }; + } + return upstreamMemo; +} + /** * TTL for FRESH cached Overpass responses (ms). Road geometry is static for * months — the original 45 s TTL forced a public-mirror round-trip on nearly @@ -153,6 +206,8 @@ export { OVERPASS_MAX_RESPONSE_BYTES, parseOverpassUpstreams, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, + cachedByConfiguredUpstream, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, }; diff --git a/server/providers/overpass/query.js b/server/providers/overpass/query.js index cee444d3a..4091c9efd 100644 --- a/server/providers/overpass/query.js +++ b/server/providers/overpass/query.js @@ -18,6 +18,29 @@ function isOverpassBoundaryQuery(cacheKey) { return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || '')); } +// Input has already had quoted literals and comments removed. Consume each +// character once: a regex that searches for a closing bracket from every `[` is +// quadratic on an unterminated filter. Nested or unmatched brackets are invalid. +function stripTagFilters(text) { + const parts = []; + let start = 0; + let inFilter = false; + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '[') { + if (inFilter) return null; + parts.push(text.slice(start, i), ' '); + inFilter = true; + } else if (text[i] === ']') { + if (!inFilter) return null; + inFilter = false; + start = i + 1; + } + } + if (inFilter) return null; + parts.push(text.slice(start)); + return parts.join(''); +} + /** * Validate + clamp an Overpass form body. Defends the generic proxy against * planet-scale abuse: requires exactly one `data` query in which EVERY element @@ -50,6 +73,10 @@ function sanitizeOverpassBody(rawBody) { // Blank quoted literals + strip comments in one lexer pass so a fake bound or a // `//` inside a string can't hide an unbounded selector (or satisfy a bound). const stripped = stripOverpassNoise(data); + const withoutTags = stripTagFilters(stripped); + if (withoutTags === null) { + return { ok: false, error: 'Malformed Overpass tag filter' }; + } // Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the // input-set form `around.set:r` — and parse the full numeric token so scientific @@ -93,20 +120,19 @@ function sanitizeOverpassBody(rawBody) { // statement. So `way[...]->.a` (global assigned to a set) is rejected, while the // app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate. const boundedSets = new Set(); - for (let stmt of stripped.split(';')) { + for (let stmt of withoutTags.split(';')) { stmt = stmt.trim(); if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue; - // Strip output-set assignments (NOT input bounds), then strip bracket tag - // filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be + // Strip output-set assignments (NOT input bounds). Bracket tag filters were + // removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be // misread as a spatial bound. Bounds live in (...) / function calls / set // refs, never inside [...], so the probe loses nothing real. const outSets = []; - const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { + const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { outSets.push(name); return ' '; }); - const probe = body.replace(/\[[^\]]*\]/g, ' '); // Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter // between the selector and the area filter (way["highway"](area.a)) can't hide diff --git a/server/providers/overpass/requestAdmission.test.mjs b/server/providers/overpass/requestAdmission.test.mjs new file mode 100644 index 000000000..ecd642320 --- /dev/null +++ b/server/providers/overpass/requestAdmission.test.mjs @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { spawnSync } from 'node:child_process'; +import { overpassProxy } from '../overpass.js'; +import { sanitizeOverpassBody } from './query.js'; + +const form = (query) => `data=${encodeURIComponent(query)}`; + +test('bracket validation rejects malformed filters without trusting their contents as bounds', () => { + for (const query of [ + 'node[', + 'node]', + 'node[[name]](around:20,1,1);out;', + 'node[name=around:20];out;', + 'node[is_in];out;', + 'node[name="(around:20,1,1)"];out;', + 'node(around:20,1,1);way[name];out;', + 'way[highway](area.a);out;', + 'node[->.a](around:20,1,1);way.a;out;', + ]) { + assert.equal(sanitizeOverpassBody(form(query)).ok, false, query); + } +}); + +test('bounded queries preserve quoted brackets, comments, multiple tags and timeout clamping', () => { + for (const query of [ + '[out:json][timeout:999];node[name="[Town]; ]"](around:20,1,1);out;', + '/* [ */way[highway][name](30.1,-97.9,30.5,-97.5);out geom;', + 'is_in(30.2,-97.7)->.a;area.a[boundary="administrative"];out tags;', + 'area(3600039152)->.x;rel(pivot.x);out geom;', + ]) { + const result = sanitizeOverpassBody(form(query)); + assert.equal(result.ok, true, query); + assert.equal( + new URLSearchParams(result.body).get('data'), + query.replace('[timeout:999]', '[timeout:30]'), + ); + } +}); + +test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => { + // A child deadline can stop synchronous regex backtracking; a test timeout in + // the same event loop cannot. The old parser takes several seconds for this. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { sanitizeOverpassBody } from ${JSON.stringify(new URL('./query.js', import.meta.url).href)}; + for (let i = 0; i < 20; i++) { + if (sanitizeOverpassBody('data=node' + '['.repeat(24000)).ok) process.exit(1); + }`, + ], + { timeout: 2000, encoding: 'utf8' }, + ); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); +}); + +function handler(hook = 'configureServer') { + let route; + overpassProxy()[hook]({ + middlewares: { + use(path, fn) { + if (path === '/api/overpass') route = fn; + }, + }, + }); + return route; +} + +async function call( + route, + { + headers = {}, + peer = '127.0.0.1', + body = 'data=node[', + forbidRead = false, + method = 'POST', + url = '/', + } = {}, +) { + let reads = 0; + const req = new Readable({ + read() { + reads++; + assert.equal(forbidRead, false, 'refused request must not read its body'); + this.push(Buffer.from(body)); + this.push(null); + }, + }); + Object.assign(req, { + method, + url, + headers: { host: 'localhost:4173', ...headers }, + socket: { remoteAddress: peer }, + }); + const res = { + statusCode: 200, + headers: {}, + setHeader(key, value) { + this.headers[key.toLowerCase()] = value; + }, + writeHead(status, values) { + this.statusCode = status; + for (const [key, value] of Object.entries(values)) + this.setHeader(key, value); + }, + end(value) { + this.body = value; + }, + }; + await route(req, res); + return { ...res, reads }; +} + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: cross-site requests are rejected before consuming a body or fetching`, async (t) => { + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const route = handler(hook); + for (const headers of [ + { origin: 'https://foreign.example', 'content-type': 'text/plain' }, + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { origin: 'null' }, + { 'x-forwarded-for': '127.0.0.1' }, + ]) { + const response = await call(route, { headers, forbidRead: true }); + assert.equal(response.statusCode, 403); + assert.equal(response.reads, 0); + assert.equal(response.headers['cache-control'], 'no-store'); + } + }); +} + +test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => { + const route = handler(); + for (const headers of [ + {}, + { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, + { host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' }, + ]) { + const response = await call(route, { headers }); + assert.equal(response.statusCode, 400); + assert.equal(response.reads, 1); + assert.match(response.body, /Malformed Overpass tag filter/); + } + const response = await call(route, { + method: 'GET', + url: '/status', + forbidRead: true, + }); + assert.equal(response.statusCode, 200); + assert.equal(typeof JSON.parse(response.body).configured, 'boolean'); +}); + +test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => { + let now = Date.now() + 120000; + t.mock.method(Date, 'now', () => now); + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const route = handler(); + const peer = 'admission-limit-test'; + for (let i = 0; i < 300; i++) { + assert.equal((await call(route, { peer })).statusCode, 400); + } + const denied = await call(route, { peer, forbidRead: true }); + assert.equal(denied.statusCode, 429); + assert.equal(denied.reads, 0); + assert.equal(denied.headers['retry-after'], '60'); + assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400); + now += 60001; + assert.equal((await call(route, { peer })).statusCode, 400); +}); diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 86b9eae15..e3776e291 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -1,6 +1,7 @@ import { OVERPASS_MAX_RESPONSE_BYTES, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, } from './constants.js'; @@ -13,7 +14,7 @@ import { simplifyOverpassPayloadBody } from './geometry.js'; * Checks for known rate-limit phrases in the body text regardless of * HTTP status code, since some mirrors return 200 with an error payload. * - * @param {string} bodyText - Upstream response body. + * @param {string} bodyText - The remark of a data reply, or a non-data body. * @returns {boolean} True if the body looks rate-limited. */ function overpassLooksRateLimited(bodyText) { @@ -40,6 +41,14 @@ function overpassLooksRuntimeError(bodyText) { ); } +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + /** * True only for an upstream response that is actually Overpass data. * @@ -150,15 +159,22 @@ async function fetchOverpassPayload( signal: controller.signal, }); const responseBody = await readBody(upstream, maxResponseBytes); + const parsed = parseJson(responseBody); + // An Overpass data reply reports failure in its remark; its element + // tags are data and can contain any phrase. Any other body (an error + // page, or JSON without elements) is matched whole. + const failureText = Array.isArray(parsed?.elements) + ? String(parsed.remark || '') + : responseBody; const rateLimited = upstream.status === 429 || upstream.status === 406 || - overpassLooksRateLimited(responseBody); + overpassLooksRateLimited(failureText); if ( rateLimited || upstream.status < 200 || upstream.status >= 300 || - overpassLooksRuntimeError(responseBody) + overpassLooksRuntimeError(failureText) ) { const failures = (previous?.failures || 0) + 1; const delay = retryDelay( @@ -177,7 +193,6 @@ async function fetchOverpassPayload( failure = refusal(status, delay); continue; } - const parsed = JSON.parse(responseBody); if (!Array.isArray(parsed?.elements) || parsed.remark) throw new Error('Malformed Overpass response'); cooldowns.delete(endpoint); @@ -187,6 +202,9 @@ async function fetchOverpassPayload( contentType: 'application/json', // Never retain a secret-bearing endpoint in cache or response metadata. endpoint: 'configured', + // Which configured instance produced this, as a hash rather than a URL, + // so a cached answer is not reused after the operator repoints. + upstreams: resolveOverpassUpstreamTag(), rateLimited: false, }; } catch { diff --git a/server/providers/places.js b/server/providers/places.js index 12f0688c6..9c9e88645 100644 --- a/server/providers/places.js +++ b/server/providers/places.js @@ -1,8 +1,13 @@ /** Place-search and route middleware for Node development servers. */ -export { googlePlacesContextProxy } from './places/google.js'; +export { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from './places/google.js'; export { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './places/google-key.js'; export { installRouteMiddleware } from './places/routes.js'; export { diff --git a/server/providers/places/google-key.js b/server/providers/places/google-key.js index 353bc9e24..ff5fe6356 100644 --- a/server/providers/places/google-key.js +++ b/server/providers/places/google-key.js @@ -14,12 +14,34 @@ export function keylessGooglePlacesResponse(apiKey) { } /** - * Google API key for the SERVER-SIDE calls (Places nearby/text search, the - * CCTV Street View fallback). These never reach the browser, so this key can - * be restricted by server IP and scoped to Places API + Street View Static - * API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles + - * Geocoding for the browser (#33). Splitting them is opt-in: unset, this - * falls back to the shared browser key and nothing changes. + * Geocoding speaks Google's own response shape rather than the `places: []` + * contract its two siblings use, because the browser adapters read `status` + * and `results` directly. The keyless capability reads the same way: a status + * that is not `OK` and no results, which the adapters already treat as "this + * provider did not answer" and pass to the next geocoder in the chain. + */ +export function keylessGoogleGeocodeResponse(apiKey) { + if (String(apiKey ?? '').trim()) return null; + return { + statusCode: 200, + payload: { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }, + }; +} + +/** + * Google API key for the SERVER-SIDE calls (Places nearby/text search, + * geocoding, the CCTV Street View fallback). These never reach the browser, so + * this key can be restricted by server IP and scoped to Places API + Geocoding + * API + Street View Static API — while GOOGLE_MAPS_API_KEY stays + * referrer-restricted to Map Tiles + Maps JavaScript API for the browser + * (#33, #363). Splitting them is opt-in: unset, this falls back to the shared + * browser key, which then has to carry every API both sides use and cannot + * take a referrer restriction. */ export function googleServerApiKey() { return resolveGoogleServerKey(process.env); diff --git a/server/providers/places/google.js b/server/providers/places/google.js index a2225c606..1ddaa9593 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -1,9 +1,11 @@ import { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js'; import { admitSameSite } from '../common/same-site.js'; +import { readResponseJsonCapped } from '../common/http.js'; import { projectNearbyPlaces, projectTextSearchPlaces, @@ -25,7 +27,7 @@ let _googleRateLimiter; */ export const GOOGLE_DEFAULT_PER_MIN = 120; -/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */ +/** Google cost endpoints (nearby-places, text-search, geocoding). Null only when set to 0. */ function googleRateLimiter() { if (_googleRateLimiter === undefined) _googleRateLimiter = makeCostRateLimiter( @@ -56,6 +58,51 @@ export function validatePlacesCoordinates(searchParams) { return { ok: true, latitude, longitude }; } +/** Longest accepted geocoder query. Real place names are far shorter. */ +const GEOCODE_MAX_QUERY = 200; + +/** + * Upstream deadline and body ceiling for the geocode routes. The deadline stays + * armed through body consumption, so a stalled upstream cannot hold a handler + * open, and the cap bounds a chunked or length-omitted reply. A geocode answer + * is a handful of results; 256 KiB is far above any honest one. + */ +const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000; + +const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024; + +/** Validate a free-text geocoder query before consuming request quota. */ +export function validateGeocodeAddress(searchParams) { + const address = String(searchParams.get('address') || '').trim(); + if (!address) return { ok: false, error: 'address is required' }; + if (address.length > GEOCODE_MAX_QUERY) + return { ok: false, error: 'address is too long' }; + return { ok: true, address }; +} + +/** + * Validate the optional viewport bias, which Google reads as + * `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than + * refused: the bias only ranks results, so a malformed one costs relevance, + * not an answer. + */ +export function geocodeBounds(searchParams) { + const raw = String(searchParams.get('bounds') || '').trim(); + if (!raw) return null; + const corners = raw.split('|'); + if (corners.length !== 2) return null; + const parts = corners.flatMap((corner) => corner.split(',')); + // A blank component must be refused before Number() turns it into 0 and a + // half-written corner like `30.4,` reads as the equator. + if (parts.length !== 4 || parts.some((part) => !part.trim())) return null; + const numbers = parts.map(Number); + if (!numbers.every(Number.isFinite)) return null; + const [swLat, swLon, neLat, neLon] = numbers; + if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; + if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; + return raw; +} + /** Nearby place labels and view-biased text search, with request-time key resolution. */ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey, @@ -293,6 +340,149 @@ export function googlePlacesContextProxy({ ); } }); + + /** + * Geocoding belongs on the server, not in the page. Google's Geocoding web + * service refuses referrer-restricted keys, so calling it from the browser + * forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be + * left unrestricted (#363). Here the request carries the server key, and + * the browser key no longer needs the Geocoding API at all. + * + * Unlike the two Places routes above, these answer in Google's own shape + * (`status` + `results`) rather than the `places: []` contract: the browser + * normalizers read that shape directly, and keeping it means the proxy adds + * a hop without adding a translation nobody asked for. + */ + function installGeocodeRoute(path, readQuery) { + middlewares.use(path, async (req, res) => { + const refuse = (statusCode, error) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }), + ); + }; + + // Gate first, like the Places routes above: a cross-site caller learns + // nothing about this endpoint's method surface. + if (admitSameSite(req, res)) return; + if (req.method !== 'GET') { + refuse(405, 'Method not allowed'); + return; + } + + // A keyless geocode has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the + // Places routes above). + const apiKey = resolveApiKey(); + const keyless = keylessGoogleGeocodeResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const query = readQuery(requestUrl.searchParams); + if (!query.ok) { + refuse(400, query.error); + return; + } + + // Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with the + // Places routes because both spend the same Google budget. + const _grl = googleRateLimiter(); + if (_grl && !_grl(clientKey(req))) { + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end( + JSON.stringify({ + status: 'OVER_QUERY_LIMIT', + results: [], + error: 'Rate limit exceeded', + }), + ); + return; + } + + try { + const upstream = new URL( + endpoints.geocode || + 'https://maps.googleapis.com/maps/api/geocode/json', + ); + for (const [name, value] of Object.entries(query.params)) + upstream.searchParams.set(name, value); + upstream.searchParams.set('key', apiKey); + const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS); + const response = await fetchImpl(upstream.toString(), { + redirect: 'error', + signal: deadline, + }); + // An unreadable, oversized or timed-out body is not an answer: it + // falls through to the same shaped failure as a refusal below. + const data = await readResponseJsonCapped( + response, + GEOCODE_MAX_RESPONSE_BYTES, + deadline, + ).catch(() => null); + + // A body we could not read is an upstream failure, not an answer with + // a status of its own, so it does not borrow the upstream's code. + if (!data) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }), + ); + return; + } + + res.statusCode = response.ok ? 200 : response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.end(JSON.stringify(data)); + } catch { + // Fixed public text: a transport, URL or runtime message would + // describe the server to the browser (SECURITY.md, sanitized errors). + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }), + ); + } + }); + } + + installGeocodeRoute('/api/google/geocode', (searchParams) => { + const address = validateGeocodeAddress(searchParams); + if (!address.ok) return address; + const bounds = geocodeBounds(searchParams); + return { + ok: true, + params: { + address: address.address, + ...(bounds ? { bounds } : {}), + }, + }; + }); + + installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => { + const coordinates = validatePlacesCoordinates(searchParams); + if (!coordinates.ok) return coordinates; + const { latitude, longitude } = coordinates; + return { ok: true, params: { latlng: `${latitude},${longitude}` } }; + }); } return { diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index 36a3eb730..30e6ed4ec 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -5,7 +5,7 @@ import { readResponseTextCapped } from '../common/http.js'; import { normalizeRadioBrowserStation, publicRadioStation, - cleanRadioText, + normalizeRadioTag, } from './stations.js'; import { radioMirrorOrigin, @@ -25,8 +25,9 @@ import { RADIO_USER_AGENT, RADIO_UUID_RE, RADIO_FALLBACK_MIRRORS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './constants.js'; -export async function mapRadioConcurrent(values, concurrency, mapper) { +async function mapRadioConcurrent(values, concurrency, mapper) { const results = new Array(values.length); let cursor = 0; const workers = Array.from( @@ -177,29 +178,25 @@ export function createRadioProxyMiddleware({ const rows = await fetchPath(`/json/stations/search?${params}`); if (!Array.isArray(rows)) throw new Error('Radio Browser catalog payload was not an array'); - if ( - !rows.every( - (row) => - row && - typeof row === 'object' && - !Array.isArray(row) && - typeof row.stationuuid === 'string' && - typeof row.name === 'string' && - (typeof row.url_resolved === 'string' || - typeof row.url === 'string'), - ) - ) - throw new Error( - 'Radio Browser catalog contained a malformed station row', - ); - const stations = rows + const structuralRows = rows.filter( + (row) => + row && + typeof row === 'object' && + !Array.isArray(row) && + typeof row.stationuuid === 'string' && + typeof row.name === 'string' && + (typeof row.url_resolved === 'string' || + typeof row.url === 'string'), + ); + // Malformed rows are skipped individually, but counted so the + // catalog keeps a diagnostic for upstream schema drift. Policy + // rejections in normalization (offline, non-HTTPS, codec) are normal + // filtering and are not counted. + const rejectedRows = rows.length - structuralRows.length; + const stations = structuralRows .map(normalizeRadioBrowserStation) .filter(Boolean); - const requestedTag = cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + const requestedTag = normalizeRadioTag(tag); const requestedTagCovered = !requestedTag || stations.some((station) => @@ -215,9 +212,10 @@ export function createRadioProxyMiddleware({ // also contain an accepted station tagged for the requested category. succeeded: stations.length > 0 && requestedTagCovered, stations, + rejectedRows, }; } catch { - return { succeeded: false, stations: [] }; + return { succeeded: false, stations: [], rejectedRows: 0 }; } }, ); @@ -257,6 +255,10 @@ export function createRadioProxyMiddleware({ if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS) healthReasons.push('station-coverage-below-policy'); const degraded = healthReasons.length > 0; + const rejectedStationCount = Math.min( + RADIO_REJECTED_STATION_COUNT_MAX, + outcomes.reduce((total, outcome) => total + outcome.rejectedRows, 0), + ); const coverage = { successfulQueries, totalQueries: queries.length, @@ -271,6 +273,9 @@ export function createRadioProxyMiddleware({ degraded, degradedReason: degraded ? healthReasons.join(',') : null, coverage, + // Diagnostic only: it never fails health policy, so a usable generation + // with a few skipped rows is still admitted. The client reports it. + rejectedStationCount, }; if (degraded && catalogCache) { const error = new Error( @@ -360,6 +365,7 @@ export function createRadioProxyMiddleware({ coverage: catalog.coverage || null, acceptedGeneration: catalog.acceptedGeneration ?? null, catalogInstance, + rejectedStationCount: catalog.rejectedStationCount ?? 0, }); } catch (error) { sendJson(res, 503, { diff --git a/server/providers/radio/constants.js b/server/providers/radio/constants.js index 0fee93ac6..b0e24babc 100644 --- a/server/providers/radio/constants.js +++ b/server/providers/radio/constants.js @@ -11,6 +11,9 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil( export const RADIO_USER_AGENT = 'GodsEyeView/1.0 (Radio Browser directory client)'; export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js'; +// Upper bound for the structurally malformed upstream rows reported in the +// /stations envelope. The client layer validates against the same bound. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; export const RADIO_FALLBACK_MIRRORS = Object.freeze([ 'https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js index b97b2d3c6..49279e2d2 100644 --- a/server/providers/radio/stations.js +++ b/server/providers/radio/stations.js @@ -1,5 +1,6 @@ export { cleanRadioText, + normalizeRadioTag, isNonGlobalIpv4, publicRadioHttpsUrl, normalizeRadioBrowserStation, diff --git a/server/providers/radio/transport.js b/server/providers/radio/transport.js index 7bbe33bcd..c4b7f2b5b 100644 --- a/server/providers/radio/transport.js +++ b/server/providers/radio/transport.js @@ -1,6 +1,9 @@ import https from 'node:https'; import { Readable } from 'node:stream'; import { isNonGlobalIpv4 } from './stations.js'; + +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + export function radioMirrorOrigin(value) { const hostname = String(value ?? '') .toLowerCase() @@ -124,19 +127,33 @@ export function fetchPinnedRadioResponse(url, options, addresses) { }, }, (response) => { - 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)); + // 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 || 500; + // A `Response` only represents statuses up to 599; Node never hands + // this callback a 1xx. + if (status > 599) + throw new Error( + `Radio Browser returned unsupported status ${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.resume(); + resolve(upstream); + } catch (error) { + response.destroy(); + reject(error); } - resolve( - new Response(Readable.toWeb(response), { - status: response.statusCode || 500, - statusText: response.statusMessage || '', - headers, - }), - ); }, ); request.on('error', reject); diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index a76cc590d..013fce1b1 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,9 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { providerCooldownMs } from './cooldown.js'; +import { admitSameSiteKeyless } from '../common/same-site.js'; +import { readResponseTextCapped } from '../common/http.js'; /** * Vite plugin: CelesTrak TLE proxy. @@ -23,6 +26,18 @@ import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; export function celestrakProxy() { const TLE_TTL_MS = 6 * 3600_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const groups = new Set([ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + // Space Missions (`getActiveTle`) reads the full active catalog. + 'active', + ]); + let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } const inflight = new Map(); // group -> Promise<{at, body}|null> @@ -48,10 +63,14 @@ export function celestrakProxy() { } } + /** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */ + const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + async function fetchUpstream(group) { const url = celestrakTleUrl(group); + const signal = AbortSignal.timeout(20000); const res = await fetch(url.toString(), { - signal: AbortSignal.timeout(20000), + signal, // CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a // descriptive User-Agent with a contact point. headers: { @@ -59,8 +78,21 @@ export function celestrakProxy() { 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', }, }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const body = await res.text(); + if (!res.ok) { + // Group refreshes run in parallel: a later failure without Retry-After + // must never shorten a longer deadline an earlier one already set. + cooldownUntil = Math.max( + cooldownUntil, + Date.now() + providerCooldownMs(res), + ); + void res.body?.cancel().catch(() => {}); + throw new Error(`HTTP ${res.status}`); + } + const body = await readResponseTextCapped( + res, + CELESTRAK_MAX_RESPONSE_BYTES, + signal, + ); // An upstream error page parses to zero TLEs — treat as failure, keep cache. if (!/^1 /m.test(body)) throw new Error('no TLE lines in response'); return { at: Date.now(), body }; @@ -71,12 +103,20 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (!/^[a-z0-9-]+$/i.test(group)) { + // Keyless and needed to render the satellites layer: refuse cross-site + // browsers but keep the documented reverse-proxy deployment working. + if (admitSameSiteKeyless(req, res)) return; + if (req.method !== 'GET') { + res.writeHead(405); + res.end(); + return; + } + if (!groups.has(group)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('invalid group'); return; } - const send = (status, body, cacheStatus) => { + const send = (status, body, cacheStatus, entry) => { // Guard against a double-send (e.g. a throw AFTER a response already // went out routing into the catch's send): writeHead after headersSent // throws "Cannot set headers after they are sent". @@ -84,6 +124,17 @@ export function celestrakProxy() { res.writeHead(status, { 'Content-Type': 'text/plain', 'x-tle-cache': cacheStatus, + 'Cache-Control': 'no-store', + ...(entry + ? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() } + : {}), + ...(cooldownUntil > 0 && Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), }); res.end(body); }; @@ -95,7 +146,16 @@ export function celestrakProxy() { if (entry) mem.set(group, entry); } if (entry && now - entry.at < TLE_TTL_MS) { - send(200, entry.body, 'HIT'); + send(200, entry.body, 'HIT', entry); + return; + } + if (Date.now() < cooldownUntil) { + send( + entry ? 200 : 502, + entry?.body || 'celestrak upstream cooling down', + entry ? 'STALE-ERROR' : 'NONE', + entry, + ); return; } // Stale or missing → refresh, single-flight per group. @@ -109,6 +169,8 @@ export function celestrakProxy() { return fresh; }) .catch((err) => { + if (Date.now() >= cooldownUntil) + cooldownUntil = Date.now() + 60000; console.warn( '[celestrak-proxy] refresh failed — serving cache if any', ); @@ -119,9 +181,9 @@ export function celestrakProxy() { } const fresh = await inflight.get(group); if (fresh) { - send(200, fresh.body, 'MISS'); + send(200, fresh.body, 'MISS', fresh); } else if (entry) { - send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty + send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty } else { send(502, 'celestrak fetch failed and no cache available', 'NONE'); } diff --git a/server/providers/space/cooldown.js b/server/providers/space/cooldown.js new file mode 100644 index 000000000..acb850124 --- /dev/null +++ b/server/providers/space/cooldown.js @@ -0,0 +1,12 @@ +/** Bound an upstream Retry-After delay (seconds or HTTP date). */ +export function providerCooldownMs(response, now = Date.now()) { + const raw = response?.headers?.get('retry-after')?.trim(); + const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN; + const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN; + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Number.isFinite(deadline) + ? deadline - now + : 60000; + return Math.min(30 * 60000, Math.max(5000, delay)); +} diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 50825df03..f95be2056 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { providerCooldownMs } from './cooldown.js'; import { promises as fsp } from 'node:fs'; import { readResponseTextCapped, @@ -13,6 +14,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) { const normalized = String(token || '').trim(); return { Accept: 'application/json', + 'User-Agent': + 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', ...(normalized ? { Authorization: `Token ${normalized}` } : {}), }; } @@ -28,6 +31,8 @@ export function rocketLaunchesProxy() { 'launch-library-2-v2.3.json', ); let cache = null; + let cooldownUntil = 0; + let cooldownStatus = 502; let diskLoaded = false; const inFlight = new Map(); @@ -60,7 +65,18 @@ export function rocketLaunchesProxy() { function send(res, status, body, cacheState) { res.writeHead(status, { 'Content-Type': 'application/json', - 'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store', + 'Cache-Control': + status === 200 && cacheState !== 'STALE-ERROR' + ? 'public, max-age=900' + : 'no-store', + ...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}), + ...(Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), 'X-GEV-Cache': cacheState, }); res.end(body); @@ -73,17 +89,25 @@ export function rocketLaunchesProxy() { signal: AbortSignal.timeout(20000), headers: launchLibraryRequestHeaders(), }); - const body = await readResponseTextCapped(upstream, maxResponseBytes); if (!upstream.ok) { + const deadline = Date.now() + providerCooldownMs(upstream); + // Never shorten a longer cooldown that is already in force. + if (deadline >= cooldownUntil) { + cooldownUntil = deadline; + cooldownStatus = upstream.status; + } + void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; throw error; } + const body = await readResponseTextCapped(upstream, maxResponseBytes); const parsed = JSON.parse(body); if (!Array.isArray(parsed?.results)) throw new Error('malformed upstream response'); const fresh = { at: Date.now(), body }; cache = fresh; + cooldownUntil = 0; void saveDiskCache(fresh); return fresh; } @@ -101,6 +125,16 @@ export function rocketLaunchesProxy() { return; } const stale = cache; + if (now < cooldownUntil) { + send( + res, + stale ? 200 : cooldownStatus, + stale?.body || + JSON.stringify({ error: 'Launch Library 2 unavailable' }), + stale ? 'STALE-ERROR' : 'NONE', + ); + return; + } const request = coalesceProxyRequest( inFlight, 'recent-launches', @@ -110,6 +144,10 @@ export function rocketLaunchesProxy() { const fresh = await request.promise; send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); } catch (error) { + if (Date.now() >= cooldownUntil) { + cooldownUntil = Date.now() + 60000; + cooldownStatus = 502; + } // Log only a bounded status, never upstream bodies, URLs, or credentials. const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus diff --git a/server/providers/terrain.js b/server/providers/terrain.js index e58a63ea5..f4817f602 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -8,6 +8,8 @@ import { terrainPointKey, validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSiteKeyless } from './common/same-site.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -18,13 +20,33 @@ import { * cache and serve-stale shape. Cache entries and stale fallback are keyed per * 5dp point, so reordered and partially overlapping batches reuse prior work. * Only missing/stale points go upstream; the response is rebuilt in exact - * request order. Larger requests are chunked sequentially, and one failing - * chunk does not discard the chunks that resolved. + * request order. Each request is bounded to one 64-point upstream chunk; + * larger client workloads are split by the browser terrain service. + * + * Bounds, because every distinct 5dp point is its own key: coordinates must be + * in WGS-84 range (rejected at parse), the point cache has an entry ceiling + * with oldest-first eviction, an over-large cache file is not read back, and + * the route carries an always-on per-client limiter. The route also refuses + * cross-site browser requests, so another web page cannot use the visitor's + * browser to fill the cache or spend the visitor's own rate-limit quota. + * Reverse-proxied same-origin requests are admitted: terrain is keyless and + * needed for rendering, and a 403 would send every chunk to the geoid fallback. */ -export function terrainHeightsProxy() { +export function terrainHeightsProxy(options = {}) { + // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` + // extracts these proxies by slicing to the first closing brace in column + // zero, so a multi-line parameter list would cut the function in half. + // Overridable so the ceilings can be exercised as behaviour rather than + // asserted as constants; production constructs this with no arguments. + const { + cacheMaxEntries = 50_000, + cacheMaxDiskBytes = 24 * 1024 * 1024, + cachePath, + } = options; const TTL_MS = 30 * 24 * 3600_000; - const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); - const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json'); + const CACHE_PATH = + cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json'); + const CACHE_DIR = path.dirname(CACHE_PATH); // Sized against measured upstream latency, not the documented page cap. // Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12, // a 2x swing within one hour). 256 points therefore costs 22-48 s and blows @@ -33,10 +55,59 @@ export function terrainHeightsProxy() { // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of // that range. const UPSTREAM_CHUNK = 64; - const MAX_POINTS = 2000; + const MAX_POINTS = UPSTREAM_CHUNK; + /** + * Entry ceiling for the point cache, and with it the file written from it. + * + * Every distinct 5dp point is a separate key, so without a ceiling the map + * — and the JSON dumped from it verbatim — grow for as long as a caller + * supplies new coordinates. Measured against real use rather than guessed: + * this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in + * 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is + * eight times that working set and ~8 MB on disk, comfortably inside the + * 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file. + */ + const CACHE_MAX_ENTRIES = cacheMaxEntries; + /** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */ + const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes; + /** + * Requests/min/client. Terrain is keyless and free, so this guards the point + * cache and the upstream community service rather than a bill. Sized from + * measurement: across a full `npm run test:track` run — the heaviest + * exercise this app has — the route served 10 requests, peaking at 8/min. + * 90/min is an order of magnitude above that, and the band `/api/overpass` + * and `/api/military-installations` already use. + */ + const RATE_PER_MIN = 90; + const allow = makeRateLimiter({ + windowMs: 60_000, + max: RATE_PER_MIN, + globalMax: RATE_PER_MIN * 3, + }); /** @type {Map} keyed by canonical 5dp lon/lat. */ const mem = new Map(); + + /** + * Drop the oldest entries until the map is back inside its ceiling. + * The map is kept in fetch-time order (sorted once on load, and the resolver + * re-inserts every point it refetches), so the oldest are at the front and + * no write sorts the map. Terrain does not move, so evicting an old point + * costs one refetch, never a wrong answer. + */ + function evictOverflow() { + for (const key of mem.keys()) { + if (mem.size <= CACHE_MAX_ENTRIES) return; + mem.delete(key); + } + } + + /** Put the map in fetch-time order; a file is not guaranteed to be. */ + function orderByAge() { + const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); + mem.clear(); + for (const [key, entry] of byAge) mem.set(key, entry); + } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); let diskLoaded = false; @@ -47,6 +118,21 @@ export function terrainHeightsProxy() { if (diskLoaded) return; diskLoaded = true; try { + // A cache file past the ceiling is not read back into memory: loading it + // would reinstate exactly the unbounded map the ceiling exists to stop. + // The next flush rewrites a bounded one. + // + // "Cannot measure it" is not "too large": if stat is unavailable, fall + // through and let readFile decide, so an absent file behaves exactly as + // it did before this guard existed. + let sizeBytes = null; + try { + sizeBytes = (await fsp.stat(CACHE_PATH)).size; + } catch { + sizeBytes = null; + } + if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES) + throw new Error('cache file too large'); const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); const pointEntries = parsed?.version === 2 && @@ -87,6 +173,8 @@ export function terrainHeightsProxy() { } diskDirty = mem.size > 0; } + orderByAge(); + evictOverflow(); } catch { /* no disk cache yet */ } @@ -157,13 +245,25 @@ export function terrainHeightsProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/terrain/heights', async (req, res) => { + // Refuse cross-site browser requests (an or fetch from another + // site) before the limiter, cache or upstream see them. Same-origin app + // requests, non-browser loopback callers (MCP panel_request) and the app + // served behind a reverse proxy pass. + if (admitSameSiteKeyless(req, res)) return; const send = (status, bodyObj) => { if (res.headersSent) return; res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(bodyObj)); }; try { - await loadDiskOnce(); + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } const parsedUrl = new URL(req.url || '', 'http://internal'); const rawPoints = parsedUrl.searchParams.get('points'); const points = parseTerrainPoints(rawPoints); @@ -175,19 +275,23 @@ export function terrainHeightsProxy() { return; } if (points.length > MAX_POINTS) { - send(500, { + send(413, { error: `too many points (${points.length}); max ${MAX_POINTS} per request`, }); return; } + await loadDiskOnce(); const outcome = await resolveTerrainHeightRequest({ points, cache: mem, fetchMissing: fetchMissingSingleFlight, ttlMs: TTL_MS, }); - if (outcome.cacheChanged) diskDirty = true; + if (outcome.cacheChanged) { + evictOverflow(); + diskDirty = true; + } if (outcome.upstreamError) { console.warn( '[terrain-heights-proxy] refresh incomplete' + diff --git a/server/providers/traffic.js b/server/providers/traffic.js index 2a3c2e60a..2069a0892 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -1,12 +1,14 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { readResponseBytesCapped } from './common/http.js'; import { isValidTileCoord as isValidTomTomTile, utcDayKey as tomtomUtcDayKey, normalizeBudget as normalizeTomTomBudget, isOverBudget as isTomTomOverBudget, } from '../../src/data/tomtomTiles.js'; +import { admitSameSite } from './common/same-site.js'; /** * TomTom traffic-flow vector-tile proxy with a daily budget governor. @@ -34,6 +36,13 @@ import { * 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. * + * Cross-site gate: both routes refuse cross-site browser requests with 403 + * (the gate Mapillary, OpenAI and Places already use). Every uncached tile is + * a billed upstream attempt counted against the persistent daily budget, so + * without the gate any web page the user visits could fire `` requests + * at distinct tiles, spend the TomTom quota and leave the traffic layer on + * 429 budget for the rest of the UTC day. + * * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode: * status reports hasKey:false and the tile endpoint 503s {error:'no_key'} * without touching upstream — the traffic layer then stays in simulation mode. @@ -48,6 +57,8 @@ export function tomtomProxy() { const DEFAULT_DAILY_BUDGET = 6000; const MEM_MAX_ENTRIES = 256; const UPSTREAM_TIMEOUT_MS = 15000; + /** Flow tiles are well under 1 MB; this bounds a runaway body, not a budget. */ + const TOMTOM_TILE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** @type {Map} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */ const mem = new Map(); @@ -143,17 +154,23 @@ export function tomtomProxy() { 'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' + `${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`; recordUpstreamFetch(); // attempts count — upstream bills the request either way - const res = await fetch(url, { - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }); + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const buf = Buffer.from(await res.arrayBuffer()); + const bytes = await readResponseBytesCapped( + res, + TOMTOM_TILE_MAX_RESPONSE_BYTES, + signal, + ); + const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (buf.length === 0) throw new Error('empty tile body'); return buf; } const installMiddleware = (server) => { server.middlewares.use('/api/tomtom', async (req, res) => { + // Gate first: a cross-site /fetch must not reach the budget counter. + if (admitSameSite(req, res)) return; // Sanitized responses only (proxy/security baseline): no upstream // error details, and never echo the key or the upstream URL. const sendJson = (status, obj, extraHeaders = {}) => { @@ -165,14 +182,17 @@ export function tomtomProxy() { }); res.end(JSON.stringify(obj)); }; - const sendTile = (buf, cacheStatus) => { + // x-tomtom-fetched-at is when the proxy fetched the tile (ms since the + // epoch), so a client handed a STALE-* copy can say how old it is. + const sendTile = (entry, cacheStatus) => { if (res.headersSent) return; res.writeHead(200, { 'Content-Type': 'application/x-protobuf', 'Cache-Control': 'no-store', 'x-tomtom-cache': cacheStatus, + 'x-tomtom-fetched-at': String(Math.round(entry.at)), }); - res.end(buf); + res.end(entry.buf); }; try { @@ -218,14 +238,14 @@ export function tomtomProxy() { } // Fresh cache hit — never counts against the budget. if (entry && now - entry.at < TILE_TTL_MS) { - sendTile(entry.buf, 'HIT'); + sendTile(entry, 'HIT'); return; } // Budget governor: over the soft cap, last-good data beats a dead layer. if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) { if (entry) { - sendTile(entry.buf, 'STALE-BUDGET'); + sendTile(entry, 'STALE-BUDGET'); } else { sendJson(429, { error: 'budget' }); } @@ -254,9 +274,9 @@ export function tomtomProxy() { } const fresh = await inflight.get(key); if (fresh) { - sendTile(fresh.buf, 'MISS'); + sendTile(fresh, 'MISS'); } else if (entry) { - sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty + sendTile(entry, 'STALE-ERROR'); // upstream down — stale beats empty } else { sendJson(502, { error: 'upstream' }); } diff --git a/server/providers/transitHistory.js b/server/providers/transitHistory.js index f396f3d8f..4da5a8891 100644 --- a/server/providers/transitHistory.js +++ b/server/providers/transitHistory.js @@ -1,5 +1,4 @@ export { createTransitHistory, - TRANSIT_HISTORY_LIMITS, validTransitIdentifier, } from '../../src/sources/transitHistoryStore.js'; diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index 6ea9209f1..551fee7a7 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -142,9 +142,11 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde const rows = Array.from({ length: 15000 }, (_, i) => row(now, `vehicle-${i}`), ); - const start = performance.now(); + // Main-thread CPU: excludes scheduler waits and parallel GC helper threads. + const start = process.threadCpuUsage(); h.ingest(feed, rows, now); - times.push(performance.now() - start); + const usage = process.threadCpuUsage(start); + times.push((usage.user + usage.system) / 1000); } assert.equal(h.diagnostics().keys, 15000); for (let i = 0; i < 15000; i++) { @@ -154,12 +156,12 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde } assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024); t.diagnostic( - `15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`, + `15000-vehicle ingestion CPU ms: ${times.map((n) => n.toFixed(1)).join('/')}`, ); - // A ceiling that says "not quadratic", not a benchmark: 15,000 rows in - // well under two seconds on a loaded laptop or a slow CI runner. The - // per-run figure above is the diagnostic to read. - assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`); + // CPU time measures ingestion work without counting scheduler delays on + // loaded runners. Keep the two-second ceiling as a guard against quadratic + // work, not a benchmark; the per-poll figures above show any slowdown. + assert.ok(Math.max(...times) < 2000, `ingestion CPU ms: ${times}`); }); test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => { @@ -177,3 +179,26 @@ test('snapshot capacity pressure preserves members not yet visited and discloses assert.equal(recreated.fixes.length, 1); assert.equal(recreated.truncated, true); }); +test('a feed fleet-number mode outranks its route mode in the epoch context', (t) => { + const h = history(t); + const ttc = { + id: 'ttc', + historyRetention: true, + defaultMode: 'bus', + routeMode: (route) => (/^5\d\d$/.test(route ?? '') ? 'tram' : 'bus'), + vehicleMode: (id) => + /^4[4-6]\d\d$/.test(id) ? 'tram' : /^\d{4}$/.test(id) ? 'bus' : 'unknown', + }; + h.ingest( + ttc, + [ + row(100000, '8515', { routeId: '506' }), + row(100000, '4507', { routeId: null }), + row(100000, 'x1', { routeId: '501' }), + ], + 100000, + ); + assert.equal(h.get('ttc', '8515', 100000).epochs[0].mode, 'bus'); + assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram'); + assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram'); +}); diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 297081b38..71cefceed 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -43,14 +43,55 @@ export function ingestAisStreamEnvelope(envelope) { if (!mmsi) return false; if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') { + const previous = _aisStreamStatic.get(mmsi) || {}; + const live = _aisStreamVessels.get(mmsi); + const liveName = live?.name === `MMSI ${mmsi}` ? '' : live?.name; + const fields = + messageType === 'StaticDataReport' + ? { + Name: + message.ReportA?.Valid === false + ? undefined + : message.ReportA?.Name, + Type: + message.ReportB?.Valid === false + ? undefined + : message.ReportB?.ShipType, + } + : message; + // Static reports may be partial (including Class B's separate A/B parts). + // Keep known values in the cache as well as in the current live row. const staticData = { - name: vesselNameFromAis(metadata, message, _aisStreamStatic.get(mmsi)), - type: vesselTypeFromAis(message, _aisStreamStatic.get(mmsi)), - destination: stringValue(message.Destination), - imo: stringValue(message.ImoNumber ?? message.IMO), + // The envelope repeats a cached name even on Class B's type-only part. + // Only an actual static name report replaces a known live name. + name: + stringValue(fields.Name ?? fields.ShipName) || + liveName || + previous.name || + stringValue(metadata.ShipName), + type: + staticCode(fields.Type ?? fields.ShipType) || + staticCode(live?.type) || + staticCode(previous.type) || + '', + destination: + stringValue(fields.Destination) || + live?.destination || + previous.destination || + '', + imo: + staticCode(fields.ImoNumber ?? fields.IMO) || + staticCode(live?.imo) || + staticCode(previous.imo) || + '', }; _aisStreamStatic.set(mmsi, staticData); mergeAisStaticIntoLiveVessel(mmsi, staticData); + // AISStream repeats the last known position in a static message's + // MetaData, but the message has no speed, course or heading and is not a + // new fix. Rebuilding a tracked vessel from it would blank its motion and + // restamp its position time, so only merge the static fields. + if (_aisStreamVessels.has(mmsi)) return true; } const lat = numberValue( @@ -191,12 +232,10 @@ function approxMetersBetween(lat1, lon1, lat2, lon2) { function mergeAisStaticIntoLiveVessel(mmsi, staticData) { const existing = _aisStreamVessels.get(mmsi); if (!existing) return; - if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`)) - existing.name = staticData.name; - if (staticData.type && !existing.type) existing.type = staticData.type; - if (staticData.destination && !existing.destination) - existing.destination = staticData.destination; - if (staticData.imo && !existing.imo) existing.imo = staticData.imo; + if (staticData.name) existing.name = staticData.name; + if (staticData.type) existing.type = staticData.type; + if (staticData.destination) existing.destination = staticData.destination; + if (staticData.imo) existing.imo = staticData.imo; } function vesselNameFromAis(metadata, message, staticData = {}) { @@ -266,6 +305,12 @@ function stringValue(value) { return String(value).trim(); } +// AIS uses zero for unavailable ship type and IMO, not as a replacement value. +function staticCode(value) { + const number = numberValue(value); + return Number.isInteger(number) && number > 0 ? String(number) : ''; +} + function numberValue(value) { if (value === null || value === undefined || value === '') return null; const number = Number(value); diff --git a/server/providers/wind.js b/server/providers/wind.js index e953ffca3..c94fe3979 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -3,6 +3,10 @@ import { fetchIfsWind } from './wind/ifs.js'; import { decodeWindGribMessage } from './wind/decode.js'; import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js'; +// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact +// English with msg() and translates it when shown, so keep the two in sync. +const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable'; + /** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */ export function windProxy({ fetchImpl = fetch, @@ -25,7 +29,7 @@ export function windProxy({ ...(overlay === 'none' ? {} : { overlay }), unavailable: true, stale: true, - reason: 'Wind upstream unavailable', + reason: UPSTREAM_UNAVAILABLE, }, }); const sendJson = (res, value, status = 200) => { @@ -139,7 +143,7 @@ export function windProxy({ old.manifest = { ...old.manifest, stale: true, - reason: 'Wind upstream unavailable', + reason: UPSTREAM_UNAVAILABLE, }; return old; } diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 7767d3905..076baf064 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -3,7 +3,7 @@ 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'; +const IFS_BASE = 'https://data.ecmwf.int/forecasts'; /** * Select the latest IFS cycle that should be available. diff --git a/server/standalone/google-token-unavailable.js b/server/standalone/google-token-unavailable.js new file mode 100644 index 000000000..6089b5941 --- /dev/null +++ b/server/standalone/google-token-unavailable.js @@ -0,0 +1,27 @@ +/** + * Treat an unavailable Google Maps tile-token endpoint as a supported, silent + * outcome. Install after real token providers and before the unknown-API 404. + */ +export function googleTokenUnavailablePlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const pathname = String(req.url || '').split('?')[0]; + if ( + (req.method !== 'GET' && req.method !== 'HEAD') || + pathname !== '/api/google/tiles-token' + ) { + next(); + return; + } + + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + }); + }; + + return { + name: 'google-token-unavailable', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/standalone/key-setup.js b/server/standalone/key-setup.js index b8be4c036..4a36cc8a0 100644 --- a/server/standalone/key-setup.js +++ b/server/standalone/key-setup.js @@ -134,6 +134,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { }; // The gate itself is pure and unit-tested (admitKeySetupRequest in // src/keySetupCore.mjs) — this just feeds it the request. + // `code`/`params` pick the panel's sentence (src/keySetup.js); `error` is for logs. + const refusal = ({ error, code, params }) => ({ error, code, params }); const admit = (req) => admitKeySetupRequest({ method: req.method, @@ -247,18 +249,24 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { configureServer(server) { server.middlewares.use('/api/setup/status', (req, res) => { if (req.method !== 'GET') - return respond(res, 405, { error: 'Method not allowed' }); + return respond(res, 405, { + error: 'Method not allowed', + code: 'method', + }); const admission = admit(req); if (!admission.ok) - return respond(res, admission.status, { error: admission.error }); + return respond(res, admission.status, refusal(admission)); respond(res, 200, providerStatus()); }); server.middlewares.use('/api/setup/keys', (req, res) => { if (req.method !== 'POST') - return respond(res, 405, { error: 'Method not allowed' }); + return respond(res, 405, { + error: 'Method not allowed', + code: 'method', + }); const admission = admit(req); if (!admission.ok) - return respond(res, admission.status, { error: admission.error }); + return respond(res, admission.status, refusal(admission)); let body = ''; let overflowed = false; req.on('data', (chunk) => { @@ -270,15 +278,21 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { }); req.on('end', () => { if (overflowed) - return respond(res, 413, { error: 'Request too large' }); + return respond(res, 413, { + error: 'Request too large', + code: 'too-large', + }); let parsed; try { parsed = JSON.parse(body || '{}'); } catch { - return respond(res, 400, { error: 'Invalid JSON' }); + return respond(res, 400, { + error: 'Invalid JSON', + code: 'invalid-json', + }); } const verdict = validateKeySetupUpdates(parsed); - if (!verdict.ok) return respond(res, 400, { error: verdict.error }); + if (!verdict.ok) return respond(res, 400, refusal(verdict)); // Neither a replace NOR a removal may touch an externally-supplied // credential (shell env, Keychain, another workflow). This backs the // UI's read-only "configured externally" state with a real contract — @@ -289,6 +303,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { if (isExternallyManaged(name, inStore)) { return respond(res, 409, { error: `${name} is configured outside Provider Settings and can only be changed where it was set`, + code: 'external', + params: { name }, }); } } @@ -305,10 +321,16 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { ) { return respond(res, 500, { error: `The key was not saved: ${error.message}`, + code: + error.code === 'GEV_HARDEN_FAILED' + ? 'harden-failed' + : 'store-unreadable', }); } return respond(res, 500, { error: `Could not write the ${storeName()} store`, + code: 'write-failed', + params: { store: storeName() }, }); } // Live for the server-side proxies immediately; the restart below is diff --git a/server/standalone/unwatched-cache.js b/server/standalone/unwatched-cache.js new file mode 100644 index 000000000..130d0ece4 --- /dev/null +++ b/server/standalone/unwatched-cache.js @@ -0,0 +1,28 @@ +/** + * Directories the app writes to while it runs. None is source, and none + * should reload anything. + */ +const RUNTIME_WRITE_GLOBS = Object.freeze([ + '**/.gev-cache/**', + '**/.gev-logs/**', +]); + +/** + * Keep the directories the app writes to out of the dev server's file watcher. + * + * Disk-cached providers write a file per response into `.gev-cache`, and the + * voice debug log appends to `.gev-logs`. Left watched, each write costs the + * watcher stat calls on libuv's thread pool, which `getaddrinfo` shares; heavy + * cache writes have been seen to delay upstream DNS lookups. + * + * @returns {import('vite').Plugin} Config-only plugin; no server hooks. + */ +export function unwatchedRuntimeDirsPlugin() { + return { + name: 'gev-unwatched-runtime-dirs', + apply: 'serve', + config: () => ({ + server: { watch: { ignored: [...RUNTIME_WRITE_GLOBS] } }, + }), + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index a14292597..f095ec2ac 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,7 +5,9 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; +import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; const root = fileURLToPath(new URL('../../', import.meta.url)); @@ -15,11 +17,14 @@ export default defineConfig(({ command, mode }) => { for (const [key, value] of Object.entries(loaded)) { if (process.env[key] === undefined) process.env[key] = value; } + const tools = standaloneVoiceTools(); return createBrowserViteConfig({ plugins: [ - ...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }), + ...localProviderPlugins({ realtime: { tools }, gemini: { tools } }), localMcpPlugin(), + googleTokenUnavailablePlugin(), apiNotFoundPlugin(), + unwatchedRuntimeDirsPlugin(), ], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js new file mode 100644 index 000000000..4f8dad144 --- /dev/null +++ b/src/agent/worldPacket.js @@ -0,0 +1,170 @@ +import * as Cesium from 'cesium'; +import { + feedProvenanceEnvelope, + layerSnapshot, + layerSnapshots, +} from '../data/layerSnapshot.js'; +import { contextModeWord } from '../contextModePolicy.js'; + +export const TRACKABLE_FAMILIES = Object.freeze([ + { layerId: 'flights', kind: 'aircraft' }, + { layerId: 'military', kind: 'aircraft' }, + { layerId: 'ais-live-vessels', kind: 'vessel' }, + { layerId: 'satellites', kind: 'satellite' }, +]); + +const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ + { field: 'mode', emptyAs: 'off' }, + { field: 'entering', emptyAs: null }, + { field: 'priorMode', emptyAs: null }, +]); + +const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ + 'context', + 'contextRollback', +]); + +/** + * Translate context-mode payloads into the public tool vocabulary while + * retaining the internal id beside each translated field. + */ +export function withContextModeVocabulary(state) { + if (!state || typeof state !== 'object') return state; + let out = state; + const mutable = () => { + if (out === state) out = { ...state }; + return out; + }; + for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { + if (!(field in state)) continue; + const internal = state[field] ?? null; + const target = mutable(); + target[field] = contextModeWord(internal, { emptyAs }); + target[`${field}Internal`] = internal; + } + for (const field of NESTED_CONTEXT_RESULT_FIELDS) { + const nested = state[field]; + if (!nested || typeof nested !== 'object') continue; + const translated = withContextModeVocabulary(nested); + if (translated !== nested) mutable()[field] = translated; + } + return out; +} + +/** Read tracked entities from their canonical layer owners. */ +export function collectTrackedEntities(dataManager) { + const tracked = []; + for (const family of TRACKABLE_FAMILIES) { + const module = dataManager?.layers?.get(family.layerId)?.module; + if (!module) continue; + try { + const info = + family.kind === 'vessel' + ? module.getSelectedInfo?.() + : module.getTrackedInfo?.(); + if (info) + tracked.push({ + kind: family.kind, + layerId: family.layerId, + ...info, + }); + } catch { + // A not-yet-ready layer contributes no tracked observation. + } + } + return tracked; +} + +/** Read the active Contacts window from its owning awareness layer. */ +export function activeContactsWindow(dataManager) { + try { + const layer = dataManager?.layers?.get('military-awareness')?.module; + return ( + layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null + ); + } catch { + return null; + } +} + +/** + * Build the model-free fast World Packet from existing application owners. + * + * This performs no network requests and intentionally mirrors the historical + * get_current_view_state payload so voice can delegate without behavior drift. + */ +export function getWorldPacket(options = {}) { + const { + viewer, + styleManager, + dataManager, + sceneDirector = null, + now: requestedNow, + } = options ?? {}; + // A default parameter only covers undefined; null or NaN would age every + // feed against the epoch and report stale data as "just now". + const now = Number.isFinite(requestedNow) ? requestedNow : Date.now(); + if (!viewer?.camera?.positionWC) + throw new TypeError('World Packet requires a viewer camera'); + if (!dataManager || typeof dataManager.getAll !== 'function') + throw new TypeError('World Packet requires a data manager'); + + const cartographic = Cesium.Cartographic.fromCartesian( + viewer.camera.positionWC, + ); + // Read the roster once. Voice must not rewalk disabled layers: preserving the + // current enabled-only view keeps the model's context budget bounded. + const rows = dataManager.getAll(); + const enabledRows = rows.filter((layer) => layer.enabled); + const contactsWindow = activeContactsWindow(dataManager); + const contextState = + typeof styleManager?.getContextModeState === 'function' + ? withContextModeVocabulary(styleManager.getContextModeState()) + : null; + + return { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + heightM: cartographic.height, + }, + style: styleManager?.activeStyle || 'normal', + context: contextState + ? { + ...contextState, + ...(contactsWindow ? { contactsWindow } : {}), + } + : null, + cockpit: + typeof styleManager?.getCockpitState === 'function' + ? styleManager.getCockpitState() + : null, + controls: + typeof styleManager?.getControlState === 'function' + ? styleManager.getControlState() + : null, + scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, + tracked: collectTrackedEntities(dataManager), + disabledLayerCount: rows.length - enabledRows.length, + // Preserve get_current_view_state's existing compact enabled-only shape. + layers: enabledRows.map((layer) => { + const snapshot = layerSnapshot(layer, { now }); + return { + id: layer.id, + name: layer.name, + enabled: layer.enabled, + count: layer.stats?.count || 0, + error: layer.stats?.error || null, + feedState: snapshot.feedState, + source: snapshot.source, + lastUpdate: snapshot.lastUpdate, + }; + }), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(rows, { now }).filter((layer) => layer.enabled), + { now }, + ), + }; +} diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs new file mode 100644 index 000000000..063e3652c --- /dev/null +++ b/src/agent/worldPacket.test.mjs @@ -0,0 +1,335 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; + +import { getWorldPacket } from './worldPacket.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; + +function withoutVoicePresentation(result) { + const packet = { ...result }; + delete packet.say; + delete packet.display; + delete packet.schedule; + return packet; +} + +function harness() { + const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); + const viewer = { + trackedEntity: undefined, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { + canvas: { + clientWidth: 1200, + clientHeight: 800, + addEventListener() {}, + removeEventListener() {}, + }, + globe: { getHeight: () => 0 }, + tweens: [], + }, + camera: { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: Cesium.Math.toRadians(-45), + moveEnd: { addEventListener: () => () => {} }, + cancelFlight() {}, + lookAtTransform() {}, + }, + }; + const rows = [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 3, + source: 'fixture', + lastUpdate: null, + }, + }, + ]; + const dataManager = { + layers: new Map([['traffic', { module: null }]]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'normal', + getContextModeState: () => ({ mode: 'flights' }), + getCockpitState: () => null, + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + return { viewer, dataManager, styleManager }; +} + +test('World Packet preserves get_current_view_state parity', async () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + + assert.deepEqual(withoutVoicePresentation(voice), packet); + assert.equal(packet.context.mode, 'contacts'); + assert.equal(packet.context.modeInternal, 'flights'); + assert.equal(packet.layers[0].feedState, 'nominal'); + assert.equal(packet.feedProvenance.overall, 'nominal'); +}); + +test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + ]; + + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); +}); + +// The 2026-10 main voice contract omits disabled layers from the model payload. +test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => { + const { viewer, dataManager, styleManager } = harness(); + const rows = dataManager.getAll(); + rows.push({ + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 99, source: 'fixture', error: 'stale' }, + }); + let reads = 0; + dataManager.getAll = () => { + reads += 1; + return rows; + }; + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(reads, 1, 'collect the roster once rather than repeatedly'); + assert.equal(packet.disabledLayerCount, 1); + assert.deepEqual( + packet.layers.map((layer) => layer.id), + ['traffic'], + ); + assert.equal(packet.feedProvenance.overall, 'nominal'); + + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + assert.deepEqual(withoutVoicePresentation(voice), packet); +}); + +const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0); + +// Exercises every packet field: context vocabulary and Contacts window, +// cockpit, controls, scene playback, tracked families (one not ready), +// enabled and disabled layers, and an aged and an unavailable feed. +function pinnedHarness() { + const { viewer } = harness(); + const rows = [ + { + id: 'flights', + name: 'Flights', + enabled: true, + stats: { + count: 12, + source: 'fixture-adsb', + lastUpdate: PINNED_NOW - 90_000, + }, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + { + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 99, source: 'fixture', error: 'stale' }, + }, + ]; + const module = (methods) => ({ module: methods }); + const dataManager = { + layers: new Map([ + [ + 'flights', + module({ + getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }), + }), + ], + [ + 'ais-live-vessels', + module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }), + ], + [ + 'satellites', + module({ + getTrackedInfo: () => { + throw new Error('not ready'); + }, + }), + ], + [ + 'military-awareness', + module({ + getContextSnapshot: () => ({ snapshot: true }), + contactsWindowFromSnapshot: (snapshot) => + snapshot ? { windowMin: 30, count: 4 } : null, + }), + ], + ]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'night-vision', + getContextModeState: () => ({ + mode: 'flights', + entering: null, + priorMode: null, + }), + getCockpitState: () => ({ active: false }), + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + const sceneDirector = { + getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }), + }; + return { viewer, dataManager, styleManager, sceneDirector }; +} + +// Captured from main's get_current_view_state before the extraction +// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW, +// minus the speech envelope. Key order is part of the contract. +const PRE_EXTRACTION_PAYLOAD = { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: 30.259999999999994, + longitude: -97.74, + heightM: 499.99999999901877, + }, + style: 'night-vision', + context: { + mode: 'contacts', + entering: null, + priorMode: null, + modeInternal: 'flights', + enteringInternal: null, + priorModeInternal: null, + contactsWindow: { windowMin: 30, count: 4 }, + }, + cockpit: { active: false }, + controls: { hud: { visible: 'auto' } }, + scenePlayback: { playing: false, sceneId: 'fixture' }, + tracked: [ + { + kind: 'aircraft', + layerId: 'flights', + icao24: 'abc123', + callsign: 'TEST1', + }, + { kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' }, + ], + disabledLayerCount: 1, + layers: [ + { + id: 'flights', + name: 'Flights', + enabled: true, + count: 12, + error: null, + feedState: 'nominal', + source: 'fixture-adsb', + lastUpdate: 1792238310000, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + count: 0, + error: 'fixture unavailable', + feedState: 'unavailable', + source: 'fixture', + lastUpdate: 0, + }, + ], + feedProvenance: { + overall: 'unavailable', + layers: [ + { + id: 'flights', + name: 'Flights', + enabled: true, + feedState: 'nominal', + source: 'fixture-adsb', + count: 12, + lastUpdate: 1792238310000, + ageSec: 90, + ageLabel: '1m ago', + error: null, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + feedState: 'unavailable', + source: 'fixture', + count: 0, + lastUpdate: 0, + ageSec: null, + ageLabel: null, + error: 'fixture unavailable', + }, + ], + note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.', + }, +}; + +function assertPinnedPayload(actual) { + assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD); + // deepEqual ignores key order; the serialized form does not. + assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD)); +} + +test('World Packet matches the pre-extraction get_current_view_state payload', () => { + const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW }); + assertPinnedPayload(packet); +}); + +test('voice get_current_view_state still returns the pre-extraction payload', async (t) => { + t.mock.method(Date, 'now', () => PINNED_NOW); + const runner = createGevActionRunner(pinnedHarness()); + const voice = await runner('get_current_view_state'); + assertPinnedPayload(withoutVoicePresentation(voice)); +}); + +test('World Packet ages feeds against the clock when now is not a finite number', (t) => { + t.mock.method(Date, 'now', () => PINNED_NOW); + for (const now of [null, Number.NaN, '2026-10-17']) { + const packet = getWorldPacket({ ...pinnedHarness(), now }); + const flights = packet.feedProvenance.layers.find( + (layer) => layer.id === 'flights', + ); + assert.equal(flights.ageSec, 90, `now: ${String(now)}`); + assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`); + } +}); + +test('World Packet rejects null options with the camera requirement', () => { + assert.throws(() => getWorldPacket(null), /requires a viewer camera/); +}); diff --git a/src/annotations/adminOutline.test.mjs b/src/annotations/adminOutline.test.mjs index 29761a2e3..2028fe711 100644 --- a/src/annotations/adminOutline.test.mjs +++ b/src/annotations/adminOutline.test.mjs @@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => { assert.deepEqual(geocodes, []); }); +test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => { + for (const types of [['locality', 'political'], ['natural_feature']]) { + const { resolve, geocodes } = harness({ + lat: -28.0167, + lng: 153.4, + label: 'Gold Coast, Australia', + name: 'Gold Coast', + types, + }); + const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4)); + assert.equal(resolved?.source, 'geocode'); + assert.equal(resolved.lat, -28.0167); + assert.equal(resolved.lon, 153.4); + assert.equal(resolved.naturalRegion, undefined); + const outline = await resolved.resolveOutline(); + assert.equal(outline?.naturalRegion, undefined); + assert.deepEqual(geocodes, ['Gold Coast']); + } +}); + +test('natural annotation outlines require a geocoded anchor', async () => { + for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) { + const miss = harness(); + assert.equal(await miss.resolve('the Alps', viewer), null); + assert.ok(miss.geocodes.includes('the Alps')); + } + const anchored = harness({ + lat: 46.5, + lng: 10, + label: 'Alps', + name: 'Alps', + types: ['natural_feature'], + }); + const resolved = await anchored.resolve('the Alps', {}); + assert.equal(resolved.source, 'geocode'); + const outline = await resolved.resolveOutline(); + assert.equal(outline.naturalRegion, 'Alps'); + assert.ok(outline.ring.length >= 8); +}); + +test('overlapping natural candidates continue through geocoding without choosing a ring', async () => { + const { resolve, geocodes } = harness({ + lat: 45.6336, + lng: -74.86493, + label: 'Canadian Shield', + name: 'Canadian Shield', + types: ['natural_feature'], + }); + const resolved = await resolve( + 'Canadian Shield', + viewerAt(45.6336, -74.86493), + ); + assert.equal(resolved?.source, 'geocode'); + assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined); + assert.deepEqual(geocodes, ['Canadian Shield']); +}); + +test('geocoded natural outlines respect district and around intent', async () => { + for (const extra of [ + { entityKind: 'district' }, + { intent: 'around_the_thing' }, + ]) { + const { resolve, geocodes } = harness({ + lat: 46.5, + lng: 10, + label: 'Alps', + name: 'Alps', + types: + extra.entityKind === 'district' ? ['locality'] : ['natural_feature'], + }); + const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra); + assert.ok(geocodes.includes('the Alps')); + assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined); + } +}); + +test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => { + const controller = new AbortController(); + const { resolve, geocodes } = harness(); + const pending = resolve('the Alps', viewerAt(46.5, 10), { + signal: controller.signal, + }); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.deepEqual(geocodes, []); +}); + test('country asks and geocoded country scopes use the bundled outline', async () => { const direct = harness(); for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) { diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 27b049bb8..b0622579d 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,6 +1,7 @@ import { isUnavailableCapability } from '../sources/capability.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { greatCircleMeters } from '../geoDistance.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1137,12 +1138,14 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Palace of Fine Arts', color: 'amber', }, { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'The Presidio (former Army base)', color: 'green', footprint: true, @@ -1150,6 +1153,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'ILM / Lucasfilm', color: 'cyan', }, @@ -1157,6 +1161,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'next to the Marina', }, ], @@ -1186,6 +1191,7 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Palace of Fine Arts', color: 'amber', }, @@ -1200,6 +1206,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'next to the Marina', color: 'cyan', }, @@ -1223,6 +1230,7 @@ export function createAnnotationEngine({ { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'The Presidio — a former Army base', color: 'green', footprint: true, @@ -1237,6 +1245,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'ILM / Lucasfilm', color: 'red', }, @@ -1250,6 +1259,7 @@ export function createAnnotationEngine({ { type: 'route', color: 'amber', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Crissy Field shoreline', points: [ { target: 'Palace of Fine Arts, San Francisco' }, @@ -1558,16 +1568,7 @@ async function fetchRoute(coordPairs, mode, signal, service) { } function greatCircleM(a, b) { - const R = 6371000; - const toRad = (deg) => (deg * Math.PI) / 180; - const dLat = toRad(b.lat - a.lat); - const dLon = toRad(b.lon - a.lon); - const la1 = toRad(a.lat); - const la2 = toRad(b.lat); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); + return greatCircleMeters(a, b); } function formatDistance(m) { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 813163d8c..5f39a077a 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 }); globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }; const requestTimes = []; - globalThis.fetch = async () => { + globalThis.fetch = async (url) => { + if (String(url).endsWith('/status')) + return Response.json({ configured: true }); requestTimes.push(Date.now()); return httpFailure(500); }; @@ -402,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add', }; let overpassCall = 0; globalThis.fetch = async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 452d6997c..2c51213c1 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/); + assert.match(String(url), /^\/api\/google\/geocode\?/); return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -273,7 +273,7 @@ for (const fixture of [ const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: fixture.lat, lon: fixture.lon, @@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { const query = new URL(String(url)).searchParams.get('address'); return { json: async () => geocodePayload({ lat: 39.7817, @@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [ { long_name: 'Capitol Hill', types: ['neighborhood', 'political'] }, ]) { installGoogleMocks(t, async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ formatted_address: 'Washington, DC 20004, USA', types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'], @@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; return { ok: true, json: async () => ({ @@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy high: { latitude: 30.28, longitude: -97.68 }, }); // Google is asked first and exactly once; one Photon call answers it. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); }); @@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s installGoogleMocks(t, async (url, init) => { const href = String(url); - if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) }; if (href.startsWith('https://photon.komoot.io/')) { return { @@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t) const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (!photonReachable) throw new Error('network down'); return photonUp; }); diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index f28a7ae6f..538c2e5da 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -1,3 +1,5 @@ +import { t, tn } from '../i18n/index.js'; + /** * Manual whiteboard drawing: the pure half. * @@ -18,6 +20,8 @@ * `drawTool.js`. */ +import { greatCircleMeters } from '../geoDistance.js'; + export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']); export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); /** @@ -27,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); */ export const MAX_VERTICES = 512; /** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */ -export const MIN_VERTEX_SEPARATION_M = 0.5; +const MIN_VERTEX_SEPARATION_M = 0.5; /** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */ -export const MIN_PATH_LENGTH_M = 1; -export const MIN_AREA_M2 = 1; +const MIN_PATH_LENGTH_M = 1; +const MIN_AREA_M2 = 1; /** @param {string} shape @returns {'area'|'line'|'pin'} */ export function normalizeShape(shape) { @@ -46,15 +50,8 @@ export function createDrawSession(shape = 'area') { } /** Great-circle distance in metres between two {lon, lat} points. */ -export function greatCircleM(a, b) { - const R = 6371000; - const toRad = (d) => (d * Math.PI) / 180; - const dLat = toRad(b.lat - a.lat); - const dLon = toRad(b.lon - a.lon); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.sqrt(Math.min(1, h))); +function greatCircleM(a, b) { + return greatCircleMeters(a, b); } /** @@ -211,9 +208,10 @@ export function formatMeasure(session) { if (!session) return ''; if (session.shape === 'line') { const m = pathLengthM(session.vertices); - return m >= 1000 - ? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km` - : `${Math.round(m)} m`; + // Round before the kilometre cut. 999.6 m was printed as "1000 m". + const whole = Math.round(m); + if (whole < 1000) return `${whole} m`; + return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); @@ -284,20 +282,26 @@ export function closeRing(pairs) { /** One line of guidance for the person drawing, by state. */ export function drawHint(session) { - if (!session) return 'Pick a shape, then click the map.'; + if (!session) return t('Pick a shape, then click the map.'); const n = session.vertices.length; if (session.shape === 'pin') return n - ? 'Enter to place the pin, Esc to cancel.' - : 'Click where the pin goes.'; + ? t('Enter to place the pin, Esc to cancel.') + : t('Click where the pin goes.'); const need = MIN_VERTICES[session.shape] - n; - if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`; + if (need > 0) + return tn(need, 'Click {count} more point.', 'Click {count} more points.'); if (finishReason(session) === 'degenerate') { return session.shape === 'area' - ? 'Those points are in a line — move one off it to enclose an area.' - : 'That line has no length — click somewhere further away.'; + ? t('Those points are in a line — move one off it to enclose an area.') + : t('That line has no length — click somewhere further away.'); } const full = - n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : ''; - return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`; + n >= MAX_VERTICES + ? t(' · {max}-point limit reached', { max: MAX_VERTICES }) + : ''; + return t( + '{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}', + { measure: formatMeasure(session), full }, + ); } diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index 50475cc9e..be9894dda 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', ( assert.equal(wrapLongitude(180), -180); assert.equal(unwrapLongitudes([]).length, 0); }); + +test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => { + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + const lat = 999.6 / metresPerDegree; + const line = createDrawSession('line'); + addVertex(line, { lon: 0, lat: 0 }); + addVertex(line, { lon: 0, lat }); + const metres = pathLengthM(line.vertices); + assert.ok( + metres >= 999.5 && metres < 1000, + `length should round to 1000 m, got ${metres}`, + ); + assert.equal(formatMeasure(line), '1.0 km'); + assert.match(drawHint(line), /^1\.0 km · /); +}); diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 7f164b54f..9467406dd 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -30,12 +30,14 @@ * at finish, deliberately and in one place — see `finishSpec` in drawMode.js. */ import * as Cesium from 'cesium'; +import { createSegmentedControl } from '../ui/segmentedControl.js'; import { pickWorldFromScreen } from './annotationResolver.js'; import { claimPointer, pointerOwner, releasePointer, } from '../data/inputOwnership.js'; +import { pointerOwnerName } from '../data/pointerOwnerNames.js'; import { DRAW_SHAPES, MAX_VERTICES, @@ -48,8 +50,9 @@ import { removeLastVertex, } from './drawMode.js'; +import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ -export const DRAW_POINTER_OWNER = 'draw'; +const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; const COLORS = ['primary', 'amber', 'cyan', 'green', 'red']; @@ -100,6 +103,17 @@ export function initDrawTool({ viewer, annotations }) { target.addEventListener(type, listener, options); domListeners.push([target, type, listener, options]); }; + // The board lives only in memory: switching language (a reload) asks first + // while it holds a lasting mark or a shape is half drawn. + const unregisterUnsavedWork = registerUnsavedWork(() => { + if (destroyed) return null; + const lasting = (annotations.list?.() || []).some( + (mark) => mark && mark.ttlMs == null && !mark.expiring, + ); + return lasting || session?.vertices?.length > 0 + ? msg('drawings on the map') + : null; + }); const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME); // `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would @@ -197,10 +211,13 @@ export function initDrawTool({ viewer, annotations }) { // a limit rather than the harmless tail of a double-click. if (reason === 'full') setHint( - `That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`, + t( + 'That shape already has {count} points — finish it or press Backspace.', + { count: MAX_VERTICES }, + ), ); else if (reason === 'invalid') - setHint('That point is off the globe — click on the world.'); + setHint(t('That point is off the globe — click on the world.')); }; const onMove = (event) => { if (!session || session.shape === 'pin' || destroyed) return; @@ -236,11 +253,15 @@ export function initDrawTool({ viewer, annotations }) { flyTo: false, }); if (destroyed || attempt !== generation) return result; - if (result?.drawn === 0) setHint('That shape could not be placed.'); + if (result?.drawn === 0) setHint(t('That shape could not be placed.')); return result; } catch (error) { if (destroyed || attempt !== generation) return null; - setHint(`Could not place the shape: ${error?.message || error}`); + setHint( + t('Could not place the shape: {error}', { + error: error?.message || error, + }), + ); return null; } }; @@ -258,7 +279,7 @@ export function initDrawTool({ viewer, annotations }) { if (session) session = createDrawSession(shape); annotations.clear(); syncPreview(); - if (!destroyed && !active) setHint('Board cleared.'); + if (!destroyed && !active) setHint(t('Board cleared.')); }; // ---- keys: only while drawing, never while typing in another field ---- @@ -324,7 +345,11 @@ export function initDrawTool({ viewer, annotations }) { // Somebody else is using the pointer — possibly an older instance of // this same tool that has not finished tearing down. Say so instead of // half-starting. - setHint(`${pointerOwner()} is using the pointer — close it first.`); + setHint( + t('{owner} is using the pointer — close it first.', { + owner: translateKnown(pointerOwnerName(pointerOwner())), + }), + ); return active; } } @@ -408,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) { const shapeButtons = [ ...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []), ]; - for (const btn of shapeButtons) { - listen(btn, 'click', () => { - shape = normalizeShape(btn.dataset.shape); - for (const other of shapeButtons) { - const on = other === btn; - other.classList.toggle('active', on); - other.setAttribute('aria-checked', String(on)); - } + const shapeControl = createSegmentedControl({ + buttons: shapeButtons, + valueOf: (button) => button.dataset.shape, + listen, + onChange: (value) => { + shape = normalizeShape(value); + shapeControl.update(shape); if (active) { generation += 1; session = createDrawSession(shape); cursor = null; syncPreview(); } - }); - } + }, + }); listen(colorSelect, 'change', () => { color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary'; syncPreview(); @@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) { generation += 1; if (active) setActive(false); destroyed = true; + unregisterUnsavedWork(); releaseSceneHandler(); releasePointer(lease); lease = null; diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 6da4c91af..f8b9afcd1 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -6,6 +6,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { initDrawTool } from './drawTool.js'; +import { describeUnsavedWork } from '../i18n/index.js'; import { isPointerFree, pointerOwner, @@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; /* ── the tests ─────────────────────────────────────────────────────────── */ +// First in the file: tools left by later tests would answer the same registry. +test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => { + const h = harness(); + try { + let marks = []; + h.annotations.list = () => marks; + assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing'); + marks = [{ id: 'a', ttlMs: 8000, expiring: false }]; + assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work'); + marks = [{ id: 'b', ttlMs: null, expiring: false }]; + assert.deepEqual(describeUnsavedWork(), ['drawings on the map']); + marks = []; + h.element('draw-toggle').emit('click'); + h.tool.addVertex(-97.75, 30.26); + assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress'); + marks = [{ id: 'c', ttlMs: null, expiring: false }]; + await h.tool.destroy(); + assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters'); + } finally { + h.restore(); + } +}); + test('turning Draw on takes a lease and turning it off gives it back', async () => { const h = harness(); try { @@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () => h.element('draw-toggle').emit('click'); assert.equal(h.tool.active, false, 'it must not half-start'); assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); - assert.match(h.element('draw-hint').textContent, /directions/); + // Named by its tool, not by the internal owner id. + assert.equal( + h.element('draw-hint').textContent, + 'Directions is using the pointer — close it first.', + ); assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound'); releasePointer(other); diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs index 09814247b..b1a5ac524 100644 --- a/src/annotations/regionRing.test.mjs +++ b/src/annotations/regionRing.test.mjs @@ -44,16 +44,123 @@ test('region ring: Natural Earth names resolve without a geocoder', async () => 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.source, 'natural-earth'); assert.equal(region.error, undefined); } }); +test('region ring: duplicate natural names need a containing geocoded anchor', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + const miss = geocoderFor(null); + assert.equal( + await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss), + null, + ); + assert.deepEqual(miss.calls, ['Cordillera Oriental']); + + const inside = geocoderFor({ + lat: 5, + lng: -73, + label: 'Cordillera Oriental', + name: 'Cordillera Oriental', + types: ['natural_feature'], + }); + const region = await resolveRegionRingForQuery( + 'Cordillera Oriental', + undefined, + inside, + ); + assert.equal(region?.source, 'natural-earth'); + assert.equal(region.name, 'Cordillera Oriental'); + assert.ok(region.ring.length >= 8); + assert.deepEqual(inside.calls, ['Cordillera Oriental']); +}); + +test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + for (const [name, lat, lng] of [ + ['Cordillera Oriental', 30.27, -97.74], + ['Canadian Shield', 45.6336, -74.86493], + ]) { + const geocoder = geocoderFor({ + lat, + lng, + label: name, + name, + types: ['natural_feature'], + }); + assert.equal( + await resolveRegionRingForQuery(name, undefined, geocoder), + null, + ); + assert.deepEqual(geocoder.calls, [name]); + } +}); + +test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => { + const lookups = []; + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource({ + getAdministrativeAreas: async (point) => { + lookups.push(point); + return []; + }, + }), + }); + const geo = geocoderFor({ + lat: 5, + lng: -73, + label: 'Cordillera Oriental', + name: 'Cordillera Oriental', + types: ['locality'], + }); + const resolved = await resolveRegionRingForQuery( + 'Cordillera Oriental', + undefined, + geo, + ); + assert.equal( + resolved, + null, + 'an unavailable city boundary is not a natural-region outline', + ); + assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]); + assert.deepEqual(geo.calls, ['Cordillera Oriental']); +}); + +test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + const controller = new AbortController(); + const pending = resolveRegionRingForQuery('the Alps', controller.signal); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + + const later = new AbortController(); + await assert.rejects( + resolveRegionRingForQuery('Cordillera Oriental', later.signal, { + async geocode() { + later.abort(); + return { place: null }; + }, + }), + { name: 'AbortError' }, + ); +}); + test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => { let adminSignal; let release; + let lookupStarted; const { resolveRegionRingForQuery } = createAnnotationResolver({ featureSource: featureSource({ getAdministrativeAreas(_point, { signal }) { + lookupStarted = Date.now(); adminSignal = signal; return new Promise((resolve) => { release = resolve; @@ -61,7 +168,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th }, }), }); - const started = Date.now(); const region = await resolveRegionRingForQuery( 'Atlantis', undefined, @@ -73,8 +179,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th ring: null, error: 'region-timeout', }); + // Timed from the boundary lookup, so the bundled pack scans before it do not + // count. Under the 3 s default budget, so an ignored budgetMs still fails. assert.ok( - Date.now() - started < 1000, + Date.now() - lookupStarted < 2_000, 'returns at the budget, not the lookup', ); // The lookup is left running so it can fill the boundary cache. @@ -116,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn ]) { const region = await resolveRegionRingForQuery(query, undefined, geocoder); assert.equal(region?.name, name, query); + assert.equal(region.source, 'bundled-admin'); assert.ok(region.ring.length >= 8, `${query} has a real ring`); } assert.deepEqual(geocoder.calls, [], 'no geocoding'); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 7d5f55d0d..226caf140 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -29,7 +29,7 @@ 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; +const REGION_FALLBACK_BUDGET_MS = 3_000; /** Entity facts that rule out a state/county reading of the ask. */ const NON_ADMIN_ENTITY_KINDS = new Set([ @@ -103,18 +103,6 @@ export function createAnnotationResolver({ cacheWrite(cache, key, null); } - /** Wire an external AbortSignal to a local controller; returns a detach fn. */ - function linkAbort(controller, externalSignal) { - if (!externalSignal) return () => {}; - if (externalSignal.aborted) { - controller.abort(); - return () => {}; - } - const onAbort = () => controller.abort(); - externalSignal.addEventListener('abort', onAbort, { once: true }); - return () => externalSignal.removeEventListener('abort', onAbort); - } - /** * Resolve a single annotation target to a normalized world anchor. * @@ -444,6 +432,7 @@ export function createAnnotationResolver({ * AFTER the anchor mark is drawn and upgrade the mark in place. */ const resolveOutline = async () => { + signal?.throwIfAborted(); let scope = baseScope; const isAdmin = scope === 'country' || @@ -469,6 +458,7 @@ export function createAnnotationResolver({ const ne = await lookupNaturalRegionOutline(target, lat, lon).catch( () => null, ); + signal?.throwIfAborted(); if (ne) { registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); const neCentroid = ringCentroid(ne.ring); @@ -2004,8 +1994,8 @@ export function createAnnotationResolver({ /** * Region ring for ANALYST queries ("how many flights over Texas / the Alps") — * a name-only entry point that reuses this module's boundary machinery - * without the annotation pipeline. Natural Earth pack first (offline, - * instant; largest-area match is correct for a global name-only ask), then + * without the annotation pipeline. Unique Natural Earth names resolve first + * (offline); duplicate names require a geocoded containment anchor, then * geocode + admin boundary for states/countries/counties (Tier A disk-cached * Overpass, so repeat asks are instant). Returns null when the name doesn't * resolve to a region-like boundary — the analyst engine reports that @@ -2020,7 +2010,7 @@ export function createAnnotationResolver({ * @param {AbortSignal} [signal] * @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>} + * @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>} */ async function resolveRegionRingForQuery( name, @@ -2037,16 +2027,24 @@ export function createAnnotationResolver({ const q = String(name || '').trim(); if (!q) return null; const ne = await findNaturalRegion(q).catch(() => null); - if (ne?.polygons?.length) { + signal?.throwIfAborted(); + if (ne?.candidates === 1 && ne.polygons?.length) { // Largest ring carries the query scope; multi-ring regions (Andes) keep // their main cordillera — good enough for containment counting. const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0]; - if (ring?.length >= 3) return { name: ne.name, ring }; + if (ring?.length >= 3) + return { name: ne.name, ring, source: 'natural-earth' }; } // Bundled states/provinces/counties by name (offline); the main part only, // like the Natural Earth rung above. const admin = await findAdminArea(q).catch(() => null); - if (admin) return { name: admin.name, ring: [...admin.ring] }; + signal?.throwIfAborted(); + if (admin) + return { + name: admin.name, + ring: [...admin.ring], + source: 'bundled-admin', + }; const lookup = resolveAdminRegionRing(q, signal, placeSearch); if (!Number.isFinite(budgetMs)) return lookup; let timer; @@ -2067,8 +2065,26 @@ export function createAnnotationResolver({ const geo = await geocodePlace(q, null, signal, placeSearch).catch( () => null, ); + signal?.throwIfAborted(); if (!geo) return null; const scope = scopeFromTypes(geo.types); + if ( + scope === 'auto' || + (scope === 'compound' && geo.types?.includes('natural_feature')) + ) { + const natural = await lookupNaturalRegionOutline( + q, + geo.lat, + geo.lon, + ).catch(() => null); + signal?.throwIfAborted(); + if (natural) + return { + name: natural.name, + ring: natural.ring, + source: 'natural-earth', + }; + } if (!['country', 'state', 'county', 'city'].includes(scope)) return null; if (scope === 'state' || scope === 'county' || scope === 'country') { const admin = await findAdminAreaAt( @@ -2077,12 +2093,16 @@ export function createAnnotationResolver({ geo.lon, scope, ).catch(() => null); - if (admin) return { name: q, ring: [...admin.ring] }; + signal?.throwIfAborted(); + if (admin) + return { name: q, ring: [...admin.ring], source: 'bundled-admin' }; } const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch( () => null, ); - if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring }; + signal?.throwIfAborted(); + if (fp?.ring?.length >= 3) + return { name: q, ring: fp.ring, source: 'remote-boundary' }; return null; } diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index a2447ecce..c6c214fff 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -4,6 +4,12 @@ import { getActiveTrackedReadoutId, TRACKED_OVERLAY_SOURCE_ID, } from '../data/trackedReadout.js'; +import { t } from '../i18n/index.js'; + +/** A label whose detailed outline could not be resolved. */ +function outlineUnavailableLabel(label) { + return t('{label} · Detailed outline unavailable', { label: label || '' }); +} /** * Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic). @@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.poly); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.path); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer( }); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.leader); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer( } rec.anno = anno; if (anno.outlineUnavailable && rec.parts.label) { - rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`; + rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label); sizeCallout(rec.parts.label); } diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index d3ef8c7cc..dcc937c75 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -219,6 +219,17 @@ test('the separate viewer export imports without constructing a browser viewer', assert.throws(() => createApplicationViewer({}), /containers are required/); }); +test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => { + const { applicationViewerOptions } = await import('./viewer.js'); + const { StarrySky } = await import('../sky/starrySky.js'); + const creditContainer = {}; + const options = applicationViewerOptions(creditContainer); + // CesiumWidget skips scene.sun and scene.moon when skyBox is false. + assert.ok(options.skyBox instanceof StarrySky); + assert.equal(options.creditContainer, creditContainer); + options.skyBox.destroy(); +}); + function pinchFixture({ zoomEventTypes } = {}) { const handlers = new Set(); const relayed = []; diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index fe36dd76a..b0f848ffe 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -24,7 +24,10 @@ 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 { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; +import { createApplicationGnssIntegrity } from './layers/gnss.js'; +import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; @@ -118,9 +121,14 @@ export function createApplicationCatalog({ ...(resolveAsset ? { resolveAsset } : {}), }), createApplicationEarthquakes({ source: sources.earthquakes }), + createApplicationAirQuality({ source: sources['air-quality'] }), createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), + createApplicationGnssIntegrity({ + source: sources['gnss-integrity'], + }), + createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationDroneAirspace({ source: sources['drone-airspace'], }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index e298580ad..3f1e54bbd 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 31); + assert.equal(first.layers.length, 34); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -60,6 +60,8 @@ test('catalogs construct distinct layers and classification from their supplied '2026-09-21T12:00:00.000Z', ); assert.ok(first.get('fire-perimeters')); + assert.ok(first.get('gnss-integrity')); + assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('drone-airspace')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); @@ -154,7 +156,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 31); + assert.equal(catalog.layers.length, 34); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/controls.js b/src/app/controls.js index 8d9b988b5..0b479f43d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -2,8 +2,13 @@ import { catalogControlServices } from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; +import { t } from '../i18n/index.js'; -/** Construct the existing controls and camera presentation. */ +/** + * Construct the existing controls and camera presentation. `initialShareCode` + * opens a share code (the `` of `/share/`) at start-up, for a host + * whose address does not carry one. + */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, loaderStatus, @@ -12,6 +17,7 @@ export function createApplicationControls({ catalog, placeSearch, defer, + initialShareCode = null, }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { @@ -26,6 +32,8 @@ export function createApplicationControls({ requestServices: operations.requests, mapStackController, placeSearch, + // A share code to open at start-up instead of the page's address. + initialShareCode, }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); @@ -41,10 +49,10 @@ export function createApplicationControls({ // If no share link state, do default fly-to Austin if (!styleManager.hasShareState) { - loaderStatus.textContent = 'Flying to Austin, TX...'; + loaderStatus.textContent = t('Flying to Austin, TX...'); defer(flyToAustin(viewer)); } else { - loaderStatus.textContent = 'Restoring shared view...'; + loaderStatus.textContent = t('Restoring shared view...'); } return { styleManager, weatherEffects, cockpitCloudEffects }; diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..e6c7d25e0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + visibilityTarget: document, getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/app/embed.js b/src/app/embed.js index a1485fe7e..f0d6cad0a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -15,9 +15,9 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; import { annotationsFromParams, createView } from '../view/index.js'; -export const EMBED_VIEW_MESSAGE = 'gev:view'; -export const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; -export const EMBED_READY_MESSAGE = 'gev:ready'; +const EMBED_VIEW_MESSAGE = 'gev:view'; +const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; +const EMBED_READY_MESSAGE = 'gev:ready'; const FOLLOW_LAYERS = { aircraft: 'flights', diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 2b259d7b9..d10e5717d 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -541,6 +541,7 @@ export function panelRuntime(config) { const notice = document.createElement('div'); notice.id = 'status'; notice.textContent = + // i18n-ignore: host-panel script, no app catalogs "This panel lost its 3D graphics, likely because other God's Eye " + "View panels in this conversation hold them. Use Open in God's " + 'Eye View above, or show it in a new conversation.'; @@ -568,13 +569,16 @@ export function panelRuntime(config) { queued = view; if (started) return; started = true; + // i18n-ignore: host-panel script, no app catalogs say("Loading God's Eye View…"); startApp(url, view).catch((error) => + // i18n-ignore: host-panel script, no app catalogs say(`God's Eye View could not load here: ${error?.message || error}.`), ); setTimeout(() => { if (!ready) say( + // i18n-ignore: see above "God's Eye View did not load here" + (blocked ? ` (${blocked})` : '') + ". Use Open in God's Eye View above.", @@ -616,6 +620,7 @@ export function panelRuntime(config) { if (context?.displayMode) displayMode = context.displayMode; if (context?.availableDisplayModes) expand.hidden = !context.availableDisplayModes.includes('fullscreen'); + // i18n-ignore: host-panel script, no app catalogs expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand'; }; expand.addEventListener('click', () => { @@ -640,6 +645,7 @@ export function panelRuntime(config) { appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, appInfo: { name: 'gods-eye-view', + // i18n-ignore: product name sent to the MCP host title: "God's Eye View", version: '1.0.0', }, @@ -655,6 +661,7 @@ export function panelRuntime(config) { }, (error) => say( + // i18n-ignore: host-panel script, no app catalogs "This client did not accept the God's Eye View panel" + (error?.message ? `: ${error.message}` : '.'), ), diff --git a/src/app/layers/airQuality.js b/src/app/layers/airQuality.js new file mode 100644 index 000000000..1f25c06d9 --- /dev/null +++ b/src/app/layers/airQuality.js @@ -0,0 +1,6 @@ +import { createAirQualityLayer } from '../../layers/airQuality/index.js'; +import { overlayHost } from './overlayHost.js'; +/** Wire air-quality readings to the application overlay host. */ +export function createApplicationAirQuality(options) { + return createAirQualityLayer({ overlayHost, ...options }); +} diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 60cc64d0f..99bf3fa6f 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js'; import * as sprites from '../../data/spriteOrder.js'; import * as activation from '../../cctvFocusRequest.js'; import * as overlays from '../../overlays/worldOverlay.js'; -import * as locations from '../../locations.js'; +import { CITY_POIS } from '../../cityPois.js'; import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as render from '../../renderGovernor.js'; @@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) { sprites, activation, overlays, - locations, + locations: { CITY_POIS }, picking, terrain, ground, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js new file mode 100644 index 000000000..ccca18850 --- /dev/null +++ b/src/app/layers/ghostMaps.js @@ -0,0 +1,23 @@ +import * as Cesium from 'cesium'; +import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire the S2 Underground GhostMaps layer into the application catalog. */ +export function createApplicationGhostMaps(options) { + return createGhostMapsLayer({ + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js new file mode 100644 index 000000000..46983953b --- /dev/null +++ b/src/app/layers/gnss.js @@ -0,0 +1,6 @@ +import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; + +/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ +export function createApplicationGnssIntegrity(options) { + return createGnssIntegrityLayer(options); +} diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 18a3e7a3b..268216eaa 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -1,32 +1,42 @@ import { createStreetLevelLayer } from '../../layers/streetLevel/index.js'; import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js'; +import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js'; +import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js'; +import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; +import * as scenePick from '../../data/scenePick.js'; import * as render from '../../renderGovernor.js'; +/** The browser Maps key the scene was started with (src/app/scene.js). */ +const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null; + /** - * Inject providers, or use Mapillary only when its source is supplied. - * An explicit provider list replaces the default; unused sources.mapillary is - * neither constructed nor validated, including when the list is empty. + * Mapillary, Google Street View, KartaView and Panoramax, once the Mapillary + * source is supplied; else none. + */ +function defaultProviders(sources) { + if (sources.mapillary == null) return []; + return [ + createMapillaryProvider({ source: sources.mapillary }), + createGoogleProvider({ getApiKey: googleApiKey }), + createKartaViewProvider(), + createPanoramaxProvider(), + ]; +} + +/** + * Inject providers, or use the defaults. An explicit provider list replaces + * them; an unused sources.mapillary is neither constructed nor validated, + * including when the list is empty. */ export function createApplicationStreetLevel({ - surface, sources = {}, - providers = sources.mapillary == null - ? [] - : [createMapillaryProvider({ source: sources.mapillary })], + providers = defaultProviders(sources), }) { return createStreetLevelLayer({ providers, - services: { - sprites, - picking, - input, - render, - ground: surface?.groundFloor ?? null, - meshFloor: surface?.meshFloor ?? null, - terrain: surface?.terrain ?? null, - }, + services: { sprites, picking, input, scenePick, render }, }); } diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index 99f356599..d7f2c6125 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -27,3 +27,62 @@ test('explicit providers replace the default source and an empty list removes it /array/, ); }); + +test('the defaults are Mapillary (given its source), Street View, KartaView and Panoramax, which start off and travel as provider switches', async () => { + const { createDefaultLayerState } = await import('../../data/layerState.js'); + const mapillary = { + getStatus() {}, + getTile() {}, + getSequenceImages() {}, + nearestImages() {}, + }; + const layer = createApplicationStreetLevel({ sources: { mapillary } }); + assert.deepEqual(layer.providerIds, [ + 'mapillary', + 'google', + 'kartaview', + 'panoramax', + ]); + // A fresh page and a restored default agree: Street View is billed, so off. + layer.setParams(createDefaultLayerState().options['street-level']); + assert.equal(layer.getParams().google, false); + assert.equal(layer.getParams().mapillary, true); + // A link that lit Street View carries it in the shared switch field. + layer.setParams({ providerSwitches: 'google-1' }); + assert.equal(layer.getParams().google, true); + assert.equal( + layer.getParams().providerSwitches, + 'google-1*kartaview-0*panoramax-0', + ); + // KartaView is keyless and free, but one source shows at a time. + layer.setParams({ providerSwitches: 'kartaview-1' }); + assert.equal(layer.getParams().kartaview, true); + assert.equal(layer.getParams().google, false); + assert.equal(layer.getParams().mapillary, false); + // As is Panoramax: lighting it puts KartaView out. + layer.setParams({ providerSwitches: 'panoramax-1' }); + assert.equal(layer.getParams().panoramax, true); + assert.equal(layer.getParams().kartaview, false); + assert.equal(layer.getParams().mapillary, false); + // The form the app writes (the README and CHANGELOG quote it). + assert.equal( + layer.getParams().providerSwitches, + 'google-0*kartaview-0*panoramax-1', + ); + // Without the application's Street Level source there are none. + assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); +}); + +test('every built-in provider is exported, so hosts can choose their own list', async () => { + const google = await import('gods-eye-view/layers/street-level/google'); + const kartaview = await import('gods-eye-view/layers/street-level/kartaview'); + const panoramax = await import('gods-eye-view/layers/street-level/panoramax'); + const layer = createApplicationStreetLevel({ + providers: [ + google.createGoogleProvider({ getApiKey: () => null }), + kartaview.createKartaViewProvider(), + panoramax.createPanoramaxProvider(), + ], + }); + assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']); +}); diff --git a/src/app/scene.js b/src/app/scene.js index b98a28914..62434613f 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,6 +14,7 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ export async function createApplicationScene({ @@ -42,7 +43,7 @@ export async function createApplicationScene({ else window.__GOOGLE_MAPS_API_KEY__ = previousKey; }); } - loaderStatus.textContent = 'Configuring viewer...'; + loaderStatus.textContent = t('Configuring viewer...'); // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); creditContainer.id = 'cesium-credits'; @@ -61,8 +62,8 @@ export async function createApplicationScene({ configureCreditKeyboardAccess(document); loaderStatus.textContent = googleApiKey || googleTokens || cesiumToken - ? 'Loading Google 3D Tiles...' - : 'Loading the keyless globe...'; + ? t('Loading Google 3D Tiles...') + : t('Loading the keyless globe...'); const photoreal = await loadPhotorealisticTileset(Cesium, { googleApiKey, googleTokens, @@ -90,12 +91,17 @@ export async function createApplicationScene({ tileError, ); const tileErrorDetail = describeError(tileError); - loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; + loaderStatus.textContent = t( + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + { + detail: tileErrorDetail, + }, + ); } viewer.scene.globe.show = true; } - loaderStatus.textContent = 'Initializing systems...'; + loaderStatus.textContent = t('Initializing systems...'); const mapStackController = new MapController(viewer, { requestRender: governorRequestRender, diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index c9f79e407..a12378260 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,13 +46,16 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ cyclones: contract(['weather-cyclones'], ['getSnapshot']), earthquakes: contract(['earthquakes'], ['getSnapshot']), 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + 'air-quality': contract(['air-quality'], ['getSnapshot']), + 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), + 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); /** An omitted provider is unavailable, never an authoritative empty dataset. */ -export class SourceUnavailableError extends Error { +class SourceUnavailableError extends Error { constructor(name) { super(`Data source not configured: ${name}`); this.name = 'SourceUnavailableError'; diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index cf8646e25..0969b8f7e 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 31); + assert.equal(actual.layers.length, 34); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, @@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej ); }); +test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected standalone acquisition'); + }); + const missing = catalog({}, t); + assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: missing.getSourceAvailability }, + ); + manager.register(missing.get('s2-ghostmaps')); + assert.equal(await manager.setEnabled('s2-ghostmaps', true), false); + assert.equal(manager.layers.get('s2-ghostmaps').initialized, false); + assert.equal( + createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'], + null, + ); + + let reads = 0; + let subscriptions = 0; + let unsubscribed = 0; + const source = { + getCatalog: async () => { + reads++; + return { commit: 'a'.repeat(40), products: [] }; + }, + getFile: async () => { + throw new Error('Empty catalog must not read files'); + }, + subscribe: () => { + subscriptions++; + return () => { + unsubscribed++; + }; + }, + }; + const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source }); + assert.equal(supplied['s2-ghostmaps'], source); + const configured = catalog(supplied, t); + assert.equal( + configured.getSourceAvailability('s2-ghostmaps').available, + true, + ); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'document'); + globalThis.document = { addEventListener() {}, removeEventListener() {} }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'document', previous); + else delete globalThis.document; + }); + const layer = configured.get('s2-ghostmaps'); + t.after(() => layer.destroy()); + layer.init({ scene: { requestRender() {} } }); + layer.enable(); + await layer.update(); + layer.disable(); + assert.equal(reads, 1); + assert.equal(subscriptions, 1); + assert.equal(unsubscribed, 1); + assert.equal(layer.getStats().commit, 'a'.repeat(40)); + assert.equal(fetches, 0); +}); + test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); const manager = new LayerLifecycle( diff --git a/src/app/sourceSlot.js b/src/app/sourceSlot.js deleted file mode 100644 index fe7590e3b..000000000 --- a/src/app/sourceSlot.js +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility entry; source contracts are owned by sources/. -export { createSourceSlot } from '../sources/sourceSlot.js'; diff --git a/src/app/sourceSlot.test.mjs b/src/app/sourceSlot.test.mjs deleted file mode 100644 index 1b1face0c..000000000 --- a/src/app/sourceSlot.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createSourceSlot } from './sourceSlot.js'; - -test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => { - let finish; - const slot = createSourceSlot({ read: async () => 'default' }, ['read']); - const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) }); - const pending = slot.source.read(); - const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } }); - old(); - finish('stale'); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(await slot.source.read(), 'replacement'); - assert.equal(slot.source.attribution.name, 'Replacement'); - stop(); - assert.throws(() => slot.source.read(), /not configured/); -}); - -test('an invalid replacement leaves the working source intact', async () => { - const slot = createSourceSlot({ read: async () => 42 }, ['read']); - assert.throws(() => slot.configure({}), TypeError); - assert.equal(await slot.source.read(), 42); -}); diff --git a/src/app/tools.js b/src/app/tools.js index 25d763e2d..7aea39c22 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,11 +1,13 @@ import { SceneDirector } from '../scenes/director.js'; import { initAnnotations } from '../annotations/index.js'; import { initDrawTool } from '../annotations/drawTool.js'; +import { initGeoFileImport } from '../ui/geoFileImport.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, getRenderGovernorDiagnostics, @@ -56,6 +58,16 @@ export function createApplicationTools({ // lifetime rather than to whoever last pressed the button. const drawTool = initDrawTool({ viewer, annotations }); defer(() => drawTool?.destroy()); + // DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its + // own data source. They live for the page session, so the application + // lifetime removes them and the drop listeners on the viewer container. + const geoFileImport = initGeoFileImport({ + viewer, + navigation: styleManager, + ground: operations.surface?.groundFloor, + subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener), + }); + defer(() => geoFileImport?.destroy()); // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // panel lives on the right rail, so both belong to the application // lifetime. The tileset lets the layer drape while the globe is hidden. @@ -99,6 +111,15 @@ export function createApplicationTools({ // nothing animates per frame. Installed AFTER every module above has had // its chance to register pre-install holds. (perf wave 2) installRenderGovernor(viewer); + // Cesium keeps a turned-off layer's draw commands (and so all its entities) + // alive in reused per-pass arrays; trim them after each disable. + defer( + installLayerReleaseTrim({ + scene: viewer.scene, + dataManager, + requestRender: governorRequestRender, + }), + ); // Install the explicit scope mask used by the DISPLAY controls. installScopeMask(viewer); diff --git a/src/app/viewer.js b/src/app/viewer.js index 9b6e7f631..c8b841352 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; +import { StarrySky } from '../sky/starrySky.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -102,11 +103,9 @@ export function installTrackpadPinchZoom( }; } -/** Create the standard globe viewer in caller-owned, visible containers. */ -export function createApplicationViewer({ container, creditContainer }) { - if (!container || !creditContainer) - throw new TypeError('Viewer and credit containers are required'); - const viewer = new Cesium.Viewer(container, { +/** Cesium.Viewer options of the standard globe viewer. */ +export function applicationViewerOptions(creditContainer) { + return { timeline: false, animation: false, baseLayerPicker: false, @@ -119,12 +118,30 @@ export function createApplicationViewer({ container, creditContainer }) { selectionIndicator: false, infoBox: false, baseLayer: false, + // Replaces the blocky Tycho-2 default. An option, not `skyBox: false`, + // which would also stop CesiumWidget from creating the Sun and Moon. + skyBox: new StarrySky(), creditContainer, msaaSamples: 4, contextOptions: { webgl: { preserveDrawingBuffer: true } }, - }); + }; +} + +/** Create the standard globe viewer in caller-owned, visible containers. */ +export function createApplicationViewer({ container, creditContainer }) { + if (!container || !creditContainer) + throw new TypeError('Viewer and credit containers are required'); + const viewer = new Cesium.Viewer( + container, + applicationViewerOptions(creditContainer), + ); try { viewer.targetFrameRate = 60; + // A phone pinch spans fewer screen pixels than desktop zoom gestures. + // Keep Cesium's distance-aware zoom and collision limits, with less travel + // required on touch-first screens. Mouse/trackpad sensitivity stays native. + if (globalThis.matchMedia?.('(pointer: coarse)').matches) + viewer.scene.screenSpaceCameraController.zoomFactor *= 3; // Before any tile builds a draw command: Cesium's per-vertex model // atmosphere fails to LINK on Apple's Metal backend and kills the // render loop. See app/atmosphereCompat.js. diff --git a/src/camera.js b/src/camera.js index 3941c9d48..3fd49b58c 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,51 +1,5 @@ import * as Cesium from 'cesium'; -/** - * Camera presets for notable locations. - * Phase 1 default: fly to Austin, TX on load. - */ -export const CAMERA_PRESETS = { - austin: { - destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800), - orientation: { - heading: Cesium.Math.toRadians(0), - pitch: Cesium.Math.toRadians(-35), - roll: 0.0, - }, - }, - sf: { - destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000), - orientation: { - heading: Cesium.Math.toRadians(30), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, - nyc: { - destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200), - orientation: { - heading: Cesium.Math.toRadians(-20), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, -}; - -/** - * Fly the camera to a preset location with a smooth animation. - */ -export function flyToPreset(viewer, presetName, duration = 3.0) { - const preset = CAMERA_PRESETS[presetName]; - if (!preset) return; - - viewer.camera.flyTo({ - destination: preset.destination, - orientation: preset.orientation, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); -} - /** * Set camera to Austin on load with a cinematic fly-in. * @returns {Function} Cancels the pending or active startup flight. diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs new file mode 100644 index 000000000..db9cbd852 --- /dev/null +++ b/src/cameraGlobeRotation.test.mjs @@ -0,0 +1,1322 @@ +import { GLOBE_VIEW } from './locations.js'; +import { projectEarthDiscToViewport } from './celestialRing.js'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + activeCameraMotionId, + getActiveCameraMotion, + initCameraVerbs, + interruptCameraMotion, + interruptGlobeRotation, + interruptCameraMotionIfActive, + moveCamera, +} from './cameraVerbs.js'; +import { getRenderGovernorDiagnostics } from './renderGovernor.js'; + +const radians = Cesium.Math.toRadians; +const clone = (value) => Cesium.Cartesian3.clone(value); +const longitude = (camera) => + Cesium.Cartographic.fromCartesian(camera.positionWC).longitude; +const wrappedAngle = Cesium.Math.negativePiToPi; +const hasRenderHold = () => + getRenderGovernorDiagnostics().holds.includes('camera-verb'); + +function near(actual, expected, tolerance = 1e-10, message = '') { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected}, received ${actual}`, + ); +} + +function pose(camera) { + return { + position: clone(camera.positionWC), + direction: clone(camera.directionWC), + up: clone(camera.upWC), + }; +} + +function samePose(camera, expected) { + const actual = pose(camera); + for (const key of ['position', 'direction', 'up']) { + near( + Cesium.Cartesian3.distance(actual[key], expected[key]), + 0, + key === 'position' ? 1e-6 : 1e-10, + key, + ); + } +} + +function orthonormalBasis(camera) { + const { directionWC: direction, upWC: up, rightWC: right } = camera; + for (const vector of [direction, up, right]) { + near(Cesium.Cartesian3.magnitude(vector), 1, 1e-12, 'unit camera basis'); + } + near(Cesium.Cartesian3.dot(direction, up), 0, 1e-12); + near(Cesium.Cartesian3.dot(direction, right), 0, 1e-12); + near(Cesium.Cartesian3.dot(up, right), 0, 1e-12); + near( + Cesium.Cartesian3.distance( + Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()), + right, + ), + 0, + 1e-12, + 'right-handed camera basis', + ); +} + +/** Use Cesium itself as an independent oracle for the swept camera path. */ +function arcHeights(camera, axis, angle, ellipsoid = Cesium.Ellipsoid.WGS84) { + const heights = []; + for (let i = 0; i <= 64; i += 1) { + const probe = Cesium.Camera.clone(camera); + probe.rotate(axis, (angle * i) / 64); + heights.push(ellipsoid.cartesianToCartographic(probe.positionWC).height); + } + return heights; +} + +/** Camera-space screen coordinates, before FOV/aspect scaling. */ +function projection(camera, target = Cesium.Cartesian3.ZERO) { + const offset = Cesium.Cartesian3.subtract( + target, + camera.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(offset, camera.directionWC); + return { + x: Cesium.Cartesian3.dot(offset, camera.rightWC) / depth, + y: Cesium.Cartesian3.dot(offset, camera.upWC) / depth, + depth, + }; +} + +/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */ +function harness( + t, + { + latitude = 15, + longitudeDeg = 0, + height = GLOBE_VIEW.heightM, + heading = 0, + pitch = -90, + roll = 0, + ellipsoid = Cesium.Ellipsoid.WGS84, + viewportWidth = 1280, + viewportHeight = 800, + } = {}, +) { + const realPerformance = globalThis.performance; + let nowMs = 0; + globalThis.performance = { now: () => nowMs }; + const canvas = Object.assign(new EventTarget(), { + clientWidth: viewportWidth, + clientHeight: viewportHeight, + width: viewportWidth, + height: viewportHeight, + }); + const scene = { + canvas, + drawingBufferWidth: viewportWidth, + drawingBufferHeight: viewportHeight, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + tweens: [], + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + height, + ellipsoid, + ), + orientation: { + heading: radians(heading), + pitch: radians(pitch), + roll: radians(roll), + }, + }); + const viewer = { + camera, + scene, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + clock: { onTick: new Cesium.Event() }, + }; + const target = Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + 0, + ellipsoid, + ); + initCameraVerbs(viewer, () => target); + t.after(() => { + interruptCameraMotion('test-cleanup'); + globalThis.performance = realPerformance; + }); + const tick = (seconds = 0.1) => { + nowMs += seconds * 1000; + viewer.clock.onTick.raiseEvent(viewer.clock); + }; + const finishOnce = () => { + for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'once motion must finish'); + }; + return { viewer, camera, canvas, target, tick, finishOnce }; +} + +for (const [name, sceneMode] of [ + ['2D', Cesium.SceneMode.SCENE2D], + ['Columbus', Cesium.SceneMode.COLUMBUS_VIEW], + ['morphing', Cesium.SceneMode.MORPHING], +]) { + test(`globe rotation refuses ${name} before changing the prior owner`, (t) => { + const { viewer, camera } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }) + .ok, + true, + ); + const priorId = activeCameraMotionId(); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + viewer.scene.mode = sceneMode; + let handoffs = 0; + let cancellations = 0; + camera.cancelFlight = () => { + cancellations += 1; + }; + for (const mode of ['once', 'continuous']) { + const result = moveCamera( + { motion: 'rotate_globe', direction: 'left', mode }, + () => { + handoffs += 1; + return { ok: true }; + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /3D/); + } + assert.equal(handoffs, 0); + assert.equal(cancellations, 0); + assert.equal(activeCameraMotionId(), priorId); + assert.equal(hasRenderHold(), true); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); + }); + + test(`active globe rotation yields to ${name} without changing the camera`, (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + viewer.scene.mode = sceneMode; + const before = pose(camera); + let rotations = 0; + camera.rotate = () => { + rotations += 1; + }; + tick(); + assert.equal(rotations, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const before = pose(camera); + const entity = { id: 'cockpit-aircraft', position: {} }; + viewer.trackedEntity = entity; + viewer.trackedEntityChanged.raiseEvent(entity); + assert.equal( + getActiveCameraMotion(), + null, + 'tracking acquisition must synchronously yield', + ); + viewer.trackedEntity = undefined; + viewer.trackedEntityChanged.raiseEvent(undefined); + tick(); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('tracking event keeps legacy yaw and an untracked globe rotation active', (t) => { + const { viewer } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }).ok, + true, + ); + const yawId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' }); + assert.equal(activeCameraMotionId(), yawId); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const globeId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent(undefined); + assert.equal(activeCameraMotionId(), globeId); +}); + +test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => { + const first = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + initCameraVerbs(first.viewer); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + const next = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0); + assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const id = activeCameraMotionId(); + first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); + assert.equal(activeCameraMotionId(), id); + next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +for (const latitude of [0, 30, -60, 89.5]) { + test(`globe rotation preserves Earth framing and radius from latitude ${latitude} degrees`, (t) => { + const { camera, tick, finishOnce } = harness(t, { latitude }); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const direction = clone(camera.directionWC); + const started = moveCamera({ motion: 'rotate_globe', mode: 'once' }); + assert.equal(started.ok, true); + assert.equal(started.direction, 'right'); + assert.equal(started.mode, 'once'); + for (let i = 0; i < 5; i += 1) { + tick(); + const current = projection(camera); + near(current.x, before.x); + near(current.y, before.y); + near(current.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + } + finishOnce(); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, direction), + radians(15), + ); + assert.equal(hasRenderHold(), false); + }); +} + +for (const [direction, sign] of [ + ['left', -1], + ['right', 1], +]) { + test(`globe ${direction} means visible landmark motion ${direction}`, (t) => { + const { camera, target, finishOnce } = harness(t, { latitude: 0 }); + const originalLongitude = longitude(camera); + near(projection(camera, target).x, 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(sign * projection(camera, target).x > 0.05); + near( + wrappedAngle(longitude(camera) - originalLongitude), + -sign * radians(15), + ); + }); +} + +for (const view of [ + { label: 'north-up', heading: 0 }, + { label: 'east-up', heading: 90 }, + { label: 'south-up', heading: 180 }, + { label: 'tilted east-up', heading: 90, pitch: -86 }, + { label: 'tilted and rolled', heading: 180, pitch: -86, roll: 35 }, + { label: 'near the pole', latitude: 89.5, heading: 180, roll: -25 }, +]) { + for (const [direction, sign] of [ + ['left', -1], + ['right', 1], + ]) { + test(`globe ${direction} stays screen-horizontal in a ${view.label} view`, (t) => { + const { camera, tick, finishOnce } = harness(t, view); + const ray = new Cesium.Ray( + clone(camera.positionWC), + clone(camera.directionWC), + ); + const intersection = Cesium.IntersectionTests.rayEllipsoid( + ray, + Cesium.Ellipsoid.WGS84, + ); + assert.ok(intersection, 'the original screen center must see the globe'); + const landmark = Cesium.Ray.getPoint( + ray, + intersection.start, + new Cesium.Cartesian3(), + ); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const originalLandmark = projection(camera, landmark); + near(originalLandmark.x, 0); + near(originalLandmark.y, 0); + // Exercise both bounded and continuous application of the same direction. + const mode = direction === 'left' ? 'once' : 'continuous'; + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode }).ok, + true, + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(0.25); + } + const after = projection(camera); + const movedLandmark = projection(camera, landmark); + assert.ok( + sign * movedLandmark.x > 0.001, + 'landmark must move in the requested screen direction', + ); + near(movedLandmark.y, originalLandmark.y); + near(after.x, before.x); + near(after.y, before.y); + near(after.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + }); + } +} + +test('equal left and right globe rotations restore the complete camera pose', (t) => { + const { camera, finishOnce } = harness(t, { + latitude: 42, + longitudeDeg: 170, + height: GLOBE_VIEW.heightM, + }); + const before = pose(camera); + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }); + finishOnce(); + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }); + finishOnce(); + samePose(camera, before); +}); + +test('globe rotation converts an inherited target frame without zooming or recentering', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-35), 50_000_000), + ); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + false, + ); + const before = pose(camera); + const screen = projection(camera); + const range = Cesium.Cartesian3.magnitude(camera.positionWC); + moveCamera({ + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', + }); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); + samePose(camera, before); + tick(); + tick(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), range, 1e-6); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); +}); + +for (const [speed, degreesPerSecond] of [ + ['slow', 4], + ['normal', 10], + ['fast', 20], +]) { + test(`continuous globe rotation honors ${speed} speed and explicit stop`, (t) => { + const { camera, tick } = harness(t); + const result = moveCamera({ + motion: 'rotate_globe', + mode: 'continuous', + speed, + }); + assert.equal(result.ok, true); + assert.equal(hasRenderHold(), true); + tick(); // First tick initializes the motion's wall-clock anchor. + const before = clone(camera.directionWC); + tick(0.1); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before), + radians(degreesPerSecond * 0.1), + ); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + const stopped = moveCamera({ motion: 'stop' }); + assert.equal(stopped.stopped, true); + assert.equal(hasRenderHold(), false); + const after = pose(camera); + tick(); + samePose(camera, after); + assert.equal(moveCamera({ motion: 'stop' }).stopped, false); + }); +} + +for (const event of ['pointerdown', 'touchstart', 'wheel']) { + test(`${event} immediately cancels globe rotation and releases the render hold`, (t) => { + const { camera, canvas, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + tick(); + const before = pose(camera); + canvas.dispatchEvent(new Event(event)); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + samePose(camera, before); + }); +} + +test('new navigation can cancel globe rotation before setting a new destination', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + assert.equal(interruptCameraMotion('explicit-navigation').wasActive, true); + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(110, -25, 1_000_000), + }); + const destination = pose(camera); + tick(); + samePose(camera, destination); + assert.equal(hasRenderHold(), false); +}); + +test('replacement motion rejects a stale globe owner cancellation', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + const globeId = activeCameraMotionId(); + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }); + const yawId = activeCameraMotionId(); + assert.notEqual(yawId, globeId); + assert.equal(interruptCameraMotionIfActive(globeId).wasActive, false); + assert.equal(getActiveCameraMotion()?.kind, 'rotate'); + const position = clone(camera.positionWC); + tick(); + tick(); + near(Cesium.Cartesian3.distance(camera.positionWC, position), 0, 1e-6); + assert.equal(interruptCameraMotionIfActive(yawId).wasActive, true); + assert.equal(hasRenderHold(), false); +}); + +test('replacing the viewer retires globe motion and its old clock listener', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + tick(); + const before = pose(camera); + const nextViewer = { + ...viewer, + clock: { onTick: new Cesium.Event() }, + }; + initCameraVerbs(nextViewer); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + nextViewer.clock.onTick.raiseEvent(nextViewer.clock); + samePose(camera, before); +}); + +for (const direction of ['up', 'down', 'diagonal']) { + test(`invalid globe direction ${direction} is refused before camera authority handoff`, (t) => { + const { camera } = harness(t); + const before = pose(camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe', direction }, () => { + handoffs += 1; + }); + assert.equal(result.ok, false); + assert.match(result.error, /left or right/); + assert.equal(handoffs, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +for (const mode of ['once', 'continuous']) { + for (const acquisition of ['tracking', 'reference frame']) { + test(`globe ${mode} yields to new ${acquisition} without moving the new owner`, (t) => { + const { viewer, camera, target, tick } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).ok, true); + tick(); + tick(); + assert.equal(hasRenderHold(), true); + const tracked = { id: 'new-tracked-aircraft' }; + if (acquisition === 'tracking') viewer.trackedEntity = tracked; + else + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-40), 1_000), + ); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + assert.equal( + viewer.trackedEntity, + acquisition === 'tracking' ? tracked : undefined, + ); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + tick(); + samePose(camera, before); + }); + } +} + +test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { + const { viewer, camera, target } = harness(t); + viewer.trackedEntity = { id: 'tracked-aircraft' }; + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.match(result.error, /following tracked-aircraft/); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +test('the existing Globe View preset remains unchanged', () => { + assert.deepEqual(GLOBE_VIEW, { + heightM: 18_000_000, + pitchDeg: -90, + durationS: 2.8, + }); +}); +for (const latitude of [0, 90, -90]) + for (const height of [12_500_000, 18_000_000, 30_000_000]) + test(`whole-globe admission depends on framing at latitude ${latitude}, height ${height}m`, (t) => { + const { camera, finishOnce } = harness(t, { latitude, height }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }) + .ok, + true, + ); + finishOnce(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + }); +test('polar preset admission keeps centre-distance although geodetic altitude varies', (t) => { + const { camera, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.ok(Math.min(...arcHeights(camera, camera.upWC, Math.PI * 2)) > 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous', speed: 'fast' }) + .ok, + true, + ); + for (let i = 0; i < 45; i++) tick(0.1); + assert.ok( + Cesium.Ellipsoid.WGS84.cartesianToCartographic(camera.positionWC).height < + GLOBE_VIEW.heightM - 1000, + ); + assert.equal(getActiveCameraMotion().kind, 'rotate_globe'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + moveCamera({ motion: 'stop' }); + assert.equal(hasRenderHold(), false); +}); +test('close-up refusal preserves an inherited target frame', (t) => { + const { camera, target } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, -0.5, 5000)); + const before = pose(camera), + transform = Cesium.Matrix4.clone(camera.transform); + let calls = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(calls, 0); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); +}); +test('navigation callback never releases tracking for globe rotation', (t) => { + const { viewer, camera } = harness(t); + const tracked = (viewer.trackedEntity = { id: 'tracked-aircraft' }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + viewer.trackedEntity = undefined; + }).ok, + false, + ); + assert.equal(calls, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); +}); +test('direct globe request refuses Cockpit before navigation mutation', (t) => { + const { viewer, camera } = harness(t); + initCameraVerbs(viewer, null, { isCockpitActive: () => true }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }).ok, + false, + ); + assert.equal(calls, 0); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('navigation handoff preserves the original world pose even when it levels a rolled camera', (t) => { + const { camera, tick } = harness(t, { + latitude: 50, + heading: 75, + pitch: -86, + roll: 35, + }); + const before = pose(camera); + const result = moveCamera( + { motion: 'rotate_globe', mode: 'continuous' }, + (start) => { + camera.setView({ + orientation: { heading: camera.heading, pitch: camera.pitch, roll: 0 }, + }); + return start(); + }, + ); + assert.equal(result.ok, true); + samePose(camera, before); + const screen = projection(camera); + tick(); + tick(); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); + orthonormalBasis(camera); +}); + +test('direct globe rotation cancels an existing camera flight before acquiring motion', (t) => { + const { camera } = harness(t); + let cancellations = 0; + const original = camera.cancelFlight; + camera.cancelFlight = function () { + cancellations += 1; + return original.call(this); + }; + const before = pose(camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + assert.equal(cancellations, 1); + samePose(camera, before); +}); + +test('legacy rotate remains local yaw at an unchanged camera position', (t) => { + const { camera, finishOnce } = harness(t, { latitude: 0 }); + const before = pose(camera); + moveCamera({ motion: 'rotate', direction: 'right' }); + finishOnce(); + near(Cesium.Cartesian3.distance(camera.positionWC, before.position), 0, 1e-6); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before.direction), + radians(15), + ); + assert.ok( + Math.abs(projection(camera).x) > 0.2, + 'local yaw intentionally moves Earth out of the center', + ); +}); + +test('legacy orbit still revolves around the selected ground target', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-45), 4_000)); + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + const range = Cesium.Cartesian3.distance(camera.positionWC, target); + moveCamera({ motion: 'orbit', direction: 'right', mode: 'continuous' }); + tick(); + tick(); + assert.equal(getActiveCameraMotion()?.kind, 'orbit'); + near(Cesium.Cartesian3.distance(camera.positionWC, target), range, 1e-6); + near(projection(camera, target).x, 0); + near(projection(camera, target).y, 0); + interruptCameraMotion('test-stop'); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); +}); + +// Actual UI/tool authority routes, not a direct call to the interrupt helper. +test('new UI navigation intent stops globe motion before its destination and stale ticks', async (t) => { + const { NavigationController } = await import('./ui/navigationController.js'); + const { viewer, camera, tick } = harness(t); + const navigation = new NavigationController({ + viewer, + tracking: { + flightsLayer: {}, + militaryFlightsLayer: {}, + satellitesLayer: {}, + }, + interruptCameraMotion(reason) { + assert.equal( + getActiveCameraMotion(), + null, + 'the intent stamp must retire rotation before release', + ); + return interruptCameraMotion(reason); + }, + interruptGlobeRotation, + isCockpitActive: () => false, + cancelOrientation() {}, + clearLocation() {}, + cancelShareSelection: () => false, + getDataManager: () => null, + stopOrbit() {}, + showToast() {}, + }); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + navigation._runExplicitNavigation('camera', () => { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + }); + const after = pose(camera); + tick(); + samePose(camera, after); +}); +test('orbit-toggle controller stops globe motion before toggling', async (t) => { + const { LocationNavigation } = await import('./ui/locationNavigation.js'); + const { target } = harness(t); + let toggled = false; + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + LocationNavigation.prototype._toggleOrbit.call({ + services: { interruptGlobeRotation }, + _currentTarget: target, + _currentPoi: {}, + orbitController: { + toggle() { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + toggled = true; + return true; + }, + }, + _orbitIndicator: { classList: { toggle() {} } }, + }); + assert.equal(toggled, true); +}); +for (const owner of ['tracking', 'cockpit', 'close-up']) + test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => { + const { createGevActionRunner } = await import('./voice/gevActions.js'); + const { runExplicitNavigation } = await import('./navigationPolicy.js'); + const { viewer, camera } = harness(t, { + height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM, + }); + const before = pose(camera); + if (owner === 'tracking') viewer.trackedEntity = { id: 'tracked-aircraft' }; + const tracked = viewer.trackedEntity; + let releases = 0, + stamps = 0; + const styleManager = { + getCockpitState: () => ({ active: owner === 'cockpit' }), + runImmediateNavigation(noun, navigate) { + return runExplicitNavigation({ + cockpitActive: owner === 'cockpit', + noun, + stamp() { + stamps++; + }, + release() { + releases++; + viewer.trackedEntity = undefined; + }, + navigate, + }); + }, + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { + layers: new Map(), + isEnabled: () => false, + getAll: () => [], + }, + }); + const result = await runner('move_camera', { + motion: 'rotate_globe', + direction: 'left', + }); + assert.equal(result.ok, false); + assert.equal(releases, 0); + assert.equal(stamps, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); + if (owner === 'close-up') { + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + } + }); + +for (const [label, ellipsoid] of [ + ['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }], + ['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }], + ['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)], +]) + test(`globe rotation refuses ${label} before handoff`, (t) => { + const { viewer, camera } = harness(t, { height: 100_000_000 }); + const before = pose(camera); + viewer.scene.globe.ellipsoid = ellipsoid; + let handedOff = false; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handedOff = true; + }); + assert.equal(result.ok, false); + assert.equal( + result.reason, + label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid', + ); + assert.equal(handedOff, false); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); + +test('an external radius change retires globe rotation without another write', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + camera.moveForward(1000); + const changed = pose(camera); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, changed); +}); + +test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => { + const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000); + const { camera } = harness(t, { ellipsoid, latitude: 60 }); + const before = pose(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(camera, before); +}); + +test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => { + const { camera, finishOnce, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok( + Cesium.Cartographic.fromCartesian(camera.positionWC).height < + GLOBE_VIEW.heightM - 1, + ); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.equal( + moveCamera({ + motion: 'rotate_globe', + direction: 'left', + speed: 'fast', + mode: 'continuous', + }).ok, + true, + ); + tick(); + tick(); + assert.equal(getActiveCameraMotion().speed, 'fast'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); +}); + +function projected(h) { + return projectEarthDiscToViewport( + h.viewer, + h.canvas.clientWidth, + h.canvas.clientHeight, + ); +} + +test('a smaller globe wholly inside the keyhole needs no overlay entry margin or reset', (t) => { + const h = harness(t, { latitude: 0, height: 12_500_000 }); + const disc = projected(h); + const clearance = + disc.keyholeRadius - + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) - + disc.earthRadius; + assert.ok( + clearance > 0 && clearance < 24, + `near-edge inside fit: ${clearance}`, + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +test('keyhole overlap refuses without a camera handoff or pose change', (t) => { + const h = harness(t, { latitude: 0, height: 11_000_000 }); + const before = pose(h.camera); + const disc = projected(h); + assert.ok(disc.earthRadius > disc.keyholeRadius); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(handoffs, 0); + samePose(h.camera, before); +}); + +for (const [angle, fits] of [ + [5, true], + [25, false], +]) + test(`actual off-centre globe at ${angle} degrees has fit ${fits}`, (t) => { + const h = harness(t, { height: 30_000_000 }); + h.camera.lookRight(radians(angle)); + const before = pose(h.camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, fits); + samePose(h.camera, before); + }); + +test('wide viewport uses vertical fovy rather than horizontal fov', (t) => { + const h = harness(t, { + viewportWidth: 2000, + viewportHeight: 500, + latitude: 0, + }); + assert.ok(h.camera.frustum.fovy < h.camera.frustum.fov); + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-reset-unavailable', + ); +}); + +for (const invalid of [ + 'orthographic', + 'behind', + 'zero-css-width', + 'offset-frustum', +]) + test(`unsupported ${invalid} fails without a reset offer`, (t) => { + const h = harness(t); + if (invalid === 'orthographic') + h.camera.frustum = new Cesium.OrthographicFrustum({ + width: 50_000_000, + aspectRatio: 1.6, + }); + if (invalid === 'behind') h.camera.lookRight(Math.PI); + if (invalid === 'zero-css-width') h.canvas.clientWidth = 0; + if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('a new request rechecks close-up framing rather than using previous eligibility', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.dispatchEvent(new Event('wheel')); + h.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000) }); + const before = pose(h.camera); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-view-required', + ); + samePose(h.camera, before); + assert.equal(getActiveCameraMotion(), null); +}); + +// Exact WGS84 equatorial tangent points expose the shared disc approximation. +// Keep the concrete counterexample public; an overlay approximation is not admission proof. +test('off-axis ellipsoid limb overlap must not be admitted as a fully framed globe', (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000 }); + h.camera.lookRight(radians(6)); + const disc = projected(h); + assert.ok( + disc.earthRadius + + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) < + disc.keyholeRadius, + 'the shared approximation reports a fit', + ); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const x = (radius * radius) / h.camera.positionWC.x; + const y = Math.sqrt(radius * radius - x * x); + const limbRadii = [-y, y].map((value) => { + const pixel = Cesium.SceneTransforms.worldToWindowCoordinates( + h.viewer.scene, + new Cesium.Cartesian3(x, value, 0), + ); + return Math.hypot( + pixel.x - disc.keyholeCenterX, + pixel.y - disc.keyholeCenterY, + ); + }); + assert.ok( + Math.max(...limbRadii) > disc.keyholeRadius, + 'an actual ellipsoid tangent point overlaps the keyhole', + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, false); +}); + +for (const roll of [0, 35]) + test(`off-axis boundary overlap is refused with roll ${roll}`, (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000, roll }); + h.camera.lookRight(radians(6)); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + }); + +for (const latitude of [0, 90]) + for (const pitch of [0, 15]) + test(`close-up horizon/upward view offers reset at latitude ${latitude}, pitch ${pitch}`, (t) => { + const h = harness(t, { latitude, pitch, height: 5000 }); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + samePose(h.camera, before); + }); + +for (const [viewportWidth, viewportHeight, fits] of [ + [390, 844, true], + [844, 390, true], + [800, 1200, true], + [1280, 800, true], + [3440, 1440, false], +]) + test(`unchanged reset preset is rechecked at ${viewportWidth}x${viewportHeight}`, (t) => { + const h = harness(t, { + viewportWidth, + viewportHeight, + height: GLOBE_VIEW.heightM, + latitude: 0, + }); + const disc = projected(h); + assert.equal( + disc.earthRadius < disc.keyholeRadius, + fits, + 'reset preset keyhole fit', + ); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, fits); + if (!fits) assert.equal(result.reason, 'globe-reset-unavailable'); + samePose(h.camera, before); + }); + +test('keyhole overlap after resize stops rotation before another pose write', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.clientWidth = 2000; + h.canvas.clientHeight = 500; + h.camera.frustum.aspectRatio = 4; + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + const before = pose(h.camera); + h.tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(h.camera, before); +}); + +for (const clipped of ['near', 'far', 'entirely-beyond-far']) + test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + if (clipped === 'near') h.camera.frustum.near = distance - radius + 100; + if (clipped === 'far') h.camera.frustum.far = distance + radius - 100; + if (clipped === 'entirely-beyond-far') + h.camera.frustum.far = distance - 2 * radius; + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); + }); + +test('fully depth-contained globe remains eligible at actual near/far limits', (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + h.camera.frustum.near = distance - radius - 1; + h.camera.frustum.far = distance + radius + 1; + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +for (const [near, far] of [ + [0, 1e9], + [-1, 1e9], + [NaN, 1e9], + [1, Infinity], + [10, 10], + [10, 9], +]) + test(`invalid depth limits ${near}/${far} are unsupported`, (t) => { + const h = harness(t); + h.camera.frustum.near = near; + h.camera.frustum.far = far; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('bare globe rotation defaults right and continues beyond a nudge', (t) => { + const { camera, tick } = harness(t, { latitude: 0 }); + const before = projection(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'right'); + assert.equal(result.mode, 'continuous'); + assert.equal(result.speed, 'normal'); + for (let i = 0; i < 30; i++) tick(0.1); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + near(projection(camera).x, before.x); + near(projection(camera).y, before.y); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); + assert.equal(hasRenderHold(), false); +}); +for (const [args, direction, mode, speed] of [ + [{ mode: 'once' }, 'right', 'once', 'normal'], + [{ direction: 'left', speed: 'slow' }, 'left', 'continuous', 'slow'], + [{ direction: 'left', mode: 'once', speed: 'fast' }, 'left', 'once', 'fast'], + [ + { direction: 'right', mode: 'continuous', speed: 'fast' }, + 'right', + 'continuous', + 'fast', + ], +]) + test(`explicit globe controls prevail: ${JSON.stringify(args)}`, (t) => { + const { finishOnce, tick } = harness(t); + const result = moveCamera({ motion: 'rotate_globe', ...args }); + assert.deepEqual( + [result.ok, result.direction, result.mode, result.speed], + [true, direction, mode, speed], + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + } + }); +for (const motion of ['rotate', 'pan', 'tilt', 'orbit']) + test(`legacy ${motion} retains its once default`, (t) => { + harness(t); + const result = moveCamera({ + motion, + direction: motion === 'tilt' ? 'up' : 'left', + }); + assert.equal(result.ok, true); + assert.equal(result.mode, 'once'); + }); +for (const mode of [null, '', 'invalid', 'CONTINUOUS']) + test(`invalid globe mode ${JSON.stringify(mode)} retains legacy once fallback`, (t) => { + const { finishOnce } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).mode, 'once'); + finishOnce(); + }); + +test('canvas-only clipping after resize preserves a keyhole-fitting rotation', (t) => { + const h = harness(t, { latitude: 0 }); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + h.canvas.clientWidth = 390; + h.canvas.clientHeight = 844; + h.camera.frustum.aspectRatio = 390 / 844; + const disc = projected(h); + assert.ok(disc.earthRadius < disc.keyholeRadius); + assert.ok(disc.earthRadius > h.canvas.clientWidth / 2); + const before = pose(h.camera); + h.tick(); + h.tick(); + assert.notDeepEqual(pose(h.camera).position, before.position); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + assert.equal(hasRenderHold(), true); +}); + +test('an invalid camera request leaves the active globe rotation untouched', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + const before = pose(h.camera); + const active = getActiveCameraMotion(); + assert.equal(moveCamera({ motion: 'pan' }).ok, false); + assert.deepEqual(getActiveCameraMotion(), active); + samePose(h.camera, before); + assert.equal(hasRenderHold(), true); +}); + +test('close-up ultrawide refusal does not offer an unfit reset or move the camera', (t) => { + const h = harness(t, { + viewportWidth: 3440, + viewportHeight: 1440, + height: 5000, + }); + const before = pose(h.camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-reset-unavailable'); + assert.equal(handoffs, 0); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); +}); diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 5a5fa3775..3f9de0cab 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js'; /** Minimum eye height above the rendered surface for a usable view. */ export const MIN_EYE_CLEARANCE_M = 120; /** Retries while tiles stream in; sampling fails until the mesh exists. */ -export const GUARD_ATTEMPTS = 8; -export const GUARD_INTERVAL_MS = 600; +const GUARD_ATTEMPTS = 8; +const GUARD_INTERVAL_MS = 600; /** Ignore sub-metre noise rather than nudging the camera forever. */ const GUARD_EPSILON_M = 2; diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 8a09810ea..15a316366 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,7 +1,6 @@ import { LayerBindings } from './ui/layerBindings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; @@ -224,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca 'move camera', ); ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))", + "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", 'const start = () => {', /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', @@ -262,7 +261,7 @@ test('deferred search releases only after its final authority check', () => { assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); - assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/); + assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/); assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); }); @@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => { }); test('vessel and fire layers announce valid clicks and never fly cameras', () => { - for (const [label, source] of [['vessels', vessels], ['fires', firms]]) { + for (const source of [vessels, firms]) { assert.match(source, /requestWorldFocus\(\{/); assert.doesNotMatch(source, /camera\.flyTo/); } diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 278a86206..231d9357d 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -4,7 +4,7 @@ * * One motion at a time, driven per clock tick. `once` = bounded eased nudge; * `continuous` runs until move_camera{stop}, ANY manual camera input on the - * canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool + * canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool * starts (the shared UI navigation policy releases this owner before the new * destination mutates the camera). fly_route is a * continuous motion in the same slot: a cinematic dolly along an existing @@ -13,14 +13,24 @@ * Orbit circles the current screen-center ground target holding range and * pitch while advancing heading (per-frame camera.lookAt; the lookAt * transform is cleared on every stop so manual control returns cleanly). + * Globe rotation turns the world-frame camera about its screen-up axis through + * Earth's center, preserving range and framing. Left/right name the visible + * globe motion, opposite to the camera's travel around Earth. */ import * as Cesium from 'cesium'; +import { GLOBE_VIEW } from './locations.js'; +import { + projectEarthDiscToViewport, + earthDiscScreenRadius, + getKeyholeGeometry, +} from './celestialRing.js'; import { holdContinuousRender, releaseContinuousRender, } from './renderGovernor.js'; +let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 }; const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 }; @@ -952,6 +962,7 @@ let _viewer = null; let _getTarget = null; let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state } let _tickRemover = null; +let _trackingRemover = null; let _inputRemovers = []; /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; @@ -1162,7 +1173,11 @@ function onTick() { else if (m.direction === 'right') cam.moveRight(step); else if (m.direction === 'up') cam.moveUp(step); else cam.moveDown(step); - } else if (m.kind === 'tilt' || m.kind === 'rotate') { + } else if ( + m.kind === 'tilt' || + m.kind === 'rotate' || + m.kind === 'rotate_globe' + ) { const degS = TILT_DEG_S[m.speed]; const stepRad = m.mode === 'once' @@ -1172,7 +1187,38 @@ function onTick() { (scale - (m.lastScale || 0)) : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; - if (m.kind === 'tilt') { + if (m.kind === 'rotate_globe') { + // Tracking can acquire the camera without going through navigation. + // Yield without resetting its pose/frame: the world-space arc proof + // only describes Cesium's rotation while the frame is identity. + if ( + _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || + _viewer.trackedEntity || + _isCockpitActive?.() || + !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ) { + interruptCameraMotion('globe-camera-owner-changed'); + return; + } + // Cesium rotates both position and basis about the reference-frame + // origin. The captured screen-up axis keeps left/right horizontal even + // in a tilted or rolled view; a positive angle moves the globe right. + const angle = m.direction === 'left' ? -stepRad : stepRad; + if (globeRotationFitsKeyhole() !== true) { + interruptCameraMotion('globe-framing-changed'); + return; + } + if ( + !globeRotationHasClearance(cam.positionWC) || + Math.abs( + Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius, + ) > 0.01 + ) { + interruptCameraMotion('globe-surface-limit'); + return; + } + cam.rotate(m.rotationAxis, angle); + } else if (m.kind === 'tilt') { const up = m.direction === 'up'; // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. const next = cam.pitch + (up ? stepRad : -stepRad); @@ -1206,6 +1252,133 @@ function onTick() { } } +/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ +function globeRotationHasClearance(position) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + const { minimumRadius, maximumRadius } = ellipsoid; + const radius = Cesium.Cartesian3.magnitude(position); + return ( + Number.isFinite(minimumRadius) && + minimumRadius > 0 && + Number.isFinite(maximumRadius) && + maximumRadius >= minimumRadius && + Number.isFinite(radius) && + radius > maximumRadius * (1 + 1e-12) + ); +} + +/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */ +function globeRotationFitsKeyhole(camera = _viewer.camera) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii)) + return null; + const canvas = _viewer.scene.canvas; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const frustum = camera.frustum; + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 || + !(frustum instanceof Cesium.PerspectiveFrustum) || + !Number.isFinite(frustum.near) || + frustum.near <= 0 || + !Number.isFinite(frustum.far) || + frustum.far <= frustum.near || + frustum.xOffset !== 0 || + frustum.yOffset !== 0 + ) + return null; + try { + const cam = camera; + const distance = Cesium.Cartesian3.magnitude(cam.positionWC); + const sphereRadius = ellipsoid.maximumRadius; + if (!Number.isFinite(distance) || distance <= 0) return null; + if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too. + const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy); + if (!radiusPx) return null; + const keyhole = getKeyholeGeometry(width, height); + // The known nadir reset probe uses the same exact cone below; projecting + // through SceneTransforms would read the live scene.camera instead. + if ( + camera === _viewer.camera && + !projectEarthDiscToViewport(_viewer, width, height) + ) { + // A near-surface horizon view cannot fit even when pointed at Earth; + // an orbital camera looking away is unsupported, not a close-up offer. + return radiusPx > Math.min(keyhole.radius, width / 2, height / 2) + ? false + : null; + } + // The shared disc is an overlay approximation off-axis. Bound the sphere + // against the existing keyhole cone. Canvas-edge clipping does not change + // framing during rigid globe rotation and is not a clearance condition, + // without changing the UI projection or defining another keyhole/mask. + const toCenter = Cesium.Cartesian3.negate( + cam.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC); + const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC)); + const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC)); + const focalY = height / (2 * Math.tan(frustum.fovy / 2)); + const focalX = + width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio); + if ( + ![depth, right, up, focalX, focalY].every(Number.isFinite) || + focalX <= 0 || + focalY <= 0 + ) + return null; + const halfAngle = Math.asin(sphereRadius / distance); + const centerAngle = Math.atan2(Math.hypot(right, up), depth); + const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY)); + // No overlay visibility hysteresis: a fully contained smaller globe needs no reset. + const framed = centerAngle + halfAngle <= keyholeAngle; + if (!framed) return false; + // Projection alone does not prove that a tiny globe is within depth planes. + if ( + depth - sphereRadius < frustum.near || + depth + sphereRadius > frustum.far + ) + return null; + return true; + } catch { + return null; + } +} + +/** Check the unchanged reset destination without moving the live camera. */ +function globeResetFitsKeyhole() { + try { + const camera = Cesium.Camera.clone(_viewer.camera); + const { longitude, latitude } = _viewer.camera.positionCartographic; + camera.setView({ + destination: Cesium.Cartesian3.fromRadians( + longitude, + latitude, + GLOBE_VIEW.heightM, + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg), + roll: 0, + }, + endTransform: Cesium.Matrix4.IDENTITY, + }); + return globeRotationFitsKeyhole(camera) === true; + } catch { + return false; + } +} + +/** Release only globe rotation when a new camera intent takes ownership. */ +export function interruptGlobeRotation(reason = 'camera-intent') { + if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason); + return { wasActive: false, reason, leveled: false }; +} + /** * Wire the module to the viewer once (idempotent). The view-target getter is * optional so a non-voice caller (the Directions FLY chip) can arm route @@ -1218,24 +1391,42 @@ function onTick() { * call brings none, so a callback from the old viewer can never answer for the * new one. */ -export function initCameraVerbs(viewer, getViewTargetCartesian = null) { +export function initCameraVerbs( + viewer, + getViewTargetCartesian = null, + { isCockpitActive } = {}, +) { const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null; if (_viewer === viewer) { if (nextTarget) _getTarget = nextTarget; + if (typeof isCockpitActive === 'function') + _isCockpitActive = isCockpitActive; return; } + // A globe motion belongs to the viewer whose world pose it captured. + if (_active?.kind === 'rotate_globe') + interruptCameraMotion('viewer-replaced'); _getTarget = nextTarget; + _isCockpitActive = + typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); + if (_trackingRemover) _trackingRemover(); + // Cockpit can acquire then clear tracking before the next clock tick. + // Yield on acquisition itself without changing legacy camera motions. + _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { + if (entity && _active?.kind === 'rotate_globe') + interruptCameraMotion('globe-camera-owner-changed'); + }); for (const rm of _inputRemovers) rm(); _inputRemovers = []; const canvas = viewer.scene.canvas; // ANY manual camera input reclaims control — the cancelFlight reflex. - for (const evt of ['pointerdown', 'wheel']) { + for (const evt of ['pointerdown', 'touchstart', 'wheel']) { const h = () => interruptCameraMotion('manual-input'); canvas.addEventListener(evt, h, { passive: true }); _inputRemovers.push(() => canvas.removeEventListener(evt, h)); @@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) { const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() : 'normal'; - const mode = args.mode === 'continuous' ? 'continuous' : 'once'; + const mode = + args.mode === 'continuous' || + (motion === 'rotate_globe' && args.mode === undefined) + ? 'continuous' + : 'once'; if (motion === 'stop') { const wasActiveBeforeHandoff = Boolean(_active); const stop = () => { @@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) { }; return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); } - if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) { + if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { return { ok: false, action: 'move_camera', - error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`, + error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, }; } if ( motion !== 'orbit' && + motion !== 'rotate_globe' && !['left', 'right', 'up', 'down'].includes(direction) ) { return { @@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'rotate needs left or right.', }; } + if ( + motion === 'rotate_globe' && + direction !== null && + !['left', 'right'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: 'rotate_globe needs left or right.', + }; + } if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). @@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) { } } if ( - motion === 'orbit' && - _viewer?.trackedEntity && - typeof runNavigation !== 'function' + (motion === 'rotate_globe' || + (motion === 'orbit' && typeof runNavigation !== 'function')) && + _viewer?.trackedEntity ) { // The follow camera owns a tracked view; a lookAt orbit fights it frame // by frame and stop rips the camera out (field finding). Honest refusal @@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) { return { ok: false, action: 'move_camera', - error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`, + error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`, }; } + let globePose; + if (motion === 'rotate_globe') { + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) { + return { + ok: false, + action: 'move_camera', + error: 'Globe rotation requires a 3D view.', + }; + } + const cam = _viewer.camera; + globePose = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()), + right: Cesium.Cartesian3.clone(cam.rightWC), + }; + if (_isCockpitActive?.()) + return { + ok: false, + action: 'move_camera', + reason: 'camera-owned', + error: 'Exit Cockpit before rotating the globe.', + }; + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!( + Number.isFinite(ellipsoid.minimumRadius) && + ellipsoid.minimumRadius > 0 && + Number.isFinite(ellipsoid.maximumRadius) && + ellipsoid.maximumRadius >= ellipsoid.minimumRadius && + Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position)) + )) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: + 'Globe rotation requires a finite supported ellipsoid and a clear globe view.', + }; + const fits = globeRotationFitsKeyhole(); + if (fits === null) + return { + ok: false, + action: 'move_camera', + reason: 'unsupported-view', + error: 'Globe rotation requires a supported perspective view.', + }; + if (!fits) { + const canReset = globeResetFitsKeyhole(); + return { + ok: false, + action: 'move_camera', + reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable', + error: canReset + ? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’' + : 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.', + }; + } + if (!globeRotationHasClearance(globePose.position)) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: 'Globe rotation requires a clear ellipsoid bounding sphere.', + }; + } const start = () => { interruptCameraMotion('replaced'); const state = { @@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode, }; + if (motion === 'rotate_globe') { + // Convert an inherited ground-target frame without changing the world + // pose. Rotation must pivot about Earth, never the previous local target. + const cam = _viewer.camera; + cam.cancelFlight(); + cam.lookAtTransform(Cesium.Matrix4.IDENTITY); + // Handoff may level a route's bank. Keep the caller's exact world pose. + // Copy the basis directly: setView's heading/pitch conversion is singular + // at nadir and can change the captured orientation near the poles. + Cesium.Cartesian3.clone(globePose.position, cam.position); + Cesium.Cartesian3.clone(globePose.direction, cam.direction); + Cesium.Cartesian3.clone(globePose.up, cam.up); + Cesium.Cartesian3.clone(globePose.right, cam.right); + state.rotationAxis = globePose.up; + state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position); + } if (motion === 'orbit') { if (_viewer.scene.tweens.length > 0) { // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm diff --git a/src/cctvFocusPolicy.js b/src/cctvFocusPolicy.js index 7fb2ebe1b..e11c1e1ca 100644 --- a/src/cctvFocusPolicy.js +++ b/src/cctvFocusPolicy.js @@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({ * @param {Object} [after={}] Ownership immediately after the await. * @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership. */ -export function mergeCctvEnableOwnership(before = {}, after = {}) { +function mergeCctvEnableOwnership(before = {}, after = {}) { return { trackedEntity: before.trackedEntity || after.trackedEntity || null, cockpitActive: !!(before.cockpitActive || after.cockpitActive), diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 13fbff552..333926a0f 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { runCctvLayerEnableFocus, runCctvLayerEnableTransition, diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index be05ef7cb..700a70a74 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { CCTV_WORLD_CLICK_FOCUS_DURATION_SEC, CCTV_FOCUS_REQUEST_EVENT, diff --git a/src/cityPois.js b/src/cityPois.js new file mode 100644 index 000000000..f16394030 --- /dev/null +++ b/src/cityPois.js @@ -0,0 +1,514 @@ +/** + * @module cityPois + * @description The preset cities and their landmarks — plain data with no + * Cesium, so modules that only need the names (voice cards) can import it. + */ + +/** + * Points of Interest per city. + * Each city has 5 POIs; the first is the default fly-to landmark. + * + * Field reference: + * alt — RANGE (distance from target in meters), NOT absolute altitude + * heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W) + * pitch — camera tilt in degrees (negative = looking down) + * buildingHeight — estimated height of landmark center above ground (meters) + */ +export const CITY_POIS = { + austin: { + name: 'Austin', + groundElevation: 150, // meters above WGS84 ellipsoid + viewBounds: { + southwest: { lat: 30.1, lng: -97.95 }, + northeast: { lat: 30.52, lng: -97.55 }, + }, + pois: [ + { + name: 'Texas State Capitol', + lat: 30.2747, + lon: -97.7403, + alt: 550, + pitch: -28, + heading: 180, + buildingHeight: 35, + }, + { + name: 'Frost Bank Tower', + lat: 30.2674, + lon: -97.7434, + alt: 550, + pitch: -22, + heading: 30, + buildingHeight: 80, + }, + { + name: 'Pennybacker Bridge', + lat: 30.3451, + lon: -97.7951, + alt: 500, + pitch: -25, + heading: 90, + buildingHeight: 40, + }, + { + name: 'The Jenga Tower', + lat: 30.2642, + lon: -97.75, + alt: 500, + pitch: -18, + heading: 45, + buildingHeight: 60, + }, + { + name: 'UT Tower', + lat: 30.2862, + lon: -97.7394, + alt: 500, + pitch: -22, + heading: 180, + buildingHeight: 50, + }, + ], + }, + sf: { + name: 'San Francisco', + groundElevation: 15, + viewBounds: { + southwest: { lat: 37.7, lng: -122.53 }, + northeast: { lat: 37.84, lng: -122.35 }, + }, + pois: [ + { + name: 'Golden Gate Bridge', + lat: 37.8199, + lon: -122.4783, + alt: 1400, + pitch: -20, + heading: 45, + buildingHeight: 100, + }, + { + name: 'Transamerica Pyramid', + lat: 37.7952, + lon: -122.4028, + alt: 500, + pitch: -25, + heading: 30, + buildingHeight: 85, + }, + { + name: 'Salesforce Tower', + lat: 37.7897, + lon: -122.3972, + alt: 680, + pitch: -25, + heading: 330, + buildingHeight: 100, + }, + { + name: 'Alcatraz Island', + lat: 37.8267, + lon: -122.423, + alt: 800, + pitch: -30, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Coit Tower', + lat: 37.8024, + lon: -122.4058, + alt: 420, + pitch: -30, + heading: 45, + buildingHeight: 30, + }, + ], + }, + nyc: { + name: 'New York', + groundElevation: 10, + viewBounds: { + southwest: { lat: 40.477, lng: -74.259 }, + northeast: { lat: 40.918, lng: -73.7 }, + }, + pois: [ + { + name: 'Statue of Liberty', + lat: 40.6892, + lon: -74.0445, + alt: 450, + pitch: -25, + heading: 315, + buildingHeight: 45, + }, + { + name: 'Empire State Building', + lat: 40.7484, + lon: -73.9857, + alt: 850, + pitch: -12, + heading: 30, + buildingHeight: 130, + buildingBounds: { height: 443, width: 130, depth: 75 }, + }, + { + name: 'One World Trade Center', + lat: 40.7127, + lon: -74.0134, + alt: 850, + pitch: -25, + heading: 0, + buildingHeight: 170, + }, + { + name: 'Brooklyn Bridge', + lat: 40.7061, + lon: -73.9969, + alt: 850, + pitch: -25, + heading: 45, + buildingHeight: 40, + }, + { + name: 'Chrysler Building', + lat: 40.7516, + lon: -73.9755, + alt: 700, + pitch: -20, + heading: 225, + buildingHeight: 100, + }, + ], + }, + tokyo: { + name: 'Tokyo', + groundElevation: 40, + viewBounds: { + southwest: { lat: 35.52, lng: 139.55 }, + northeast: { lat: 35.9, lng: 139.92 }, + }, + pois: [ + { + name: 'Tokyo Tower', + lat: 35.6586, + lon: 139.7454, + alt: 850, + pitch: -25, + heading: 0, + buildingHeight: 110, + }, + { + name: 'Tokyo Skytree', + lat: 35.7101, + lon: 139.8107, + alt: 900, + pitch: -25, + heading: 30, + buildingHeight: 200, + }, + { + name: 'Imperial Palace', + lat: 35.6852, + lon: 139.7528, + alt: 900, + pitch: -35, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Senso-ji Temple', + lat: 35.7148, + lon: 139.7967, + alt: 400, + pitch: -30, + heading: 180, + buildingHeight: 25, + }, + { + name: 'Mode Gakuen Cocoon Tower', + lat: 35.6929, + lon: 139.6925, + alt: 350, + pitch: -20, + heading: 30, + buildingHeight: 70, + }, + ], + }, + london: { + name: 'London', + groundElevation: 15, + viewBounds: { + southwest: { lat: 51.28, lng: -0.51 }, + northeast: { lat: 51.7, lng: 0.33 }, + }, + pois: [ + { + name: 'Tower Bridge', + lat: 51.5055, + lon: -0.0754, + alt: 400, + pitch: -25, + heading: 270, + buildingHeight: 65, + }, + { + name: 'The Shard', + lat: 51.5045, + lon: -0.0865, + alt: 850, + pitch: -20, + heading: 0, + buildingHeight: 100, + }, + { + name: 'Big Ben / Parliament', + lat: 51.5007, + lon: -0.1246, + alt: 600, + pitch: -25, + heading: 180, + buildingHeight: 50, + }, + { + name: "St. Paul's Cathedral", + lat: 51.5138, + lon: -0.0984, + alt: 400, + pitch: -30, + heading: 270, + buildingHeight: 55, + }, + { + name: 'The Gherkin', + lat: 51.5145, + lon: -0.0803, + alt: 350, + pitch: -20, + heading: 30, + buildingHeight: 60, + }, + ], + }, + paris: { + name: 'Paris', + groundElevation: 35, + viewBounds: { + southwest: { lat: 48.815, lng: 2.224 }, + northeast: { lat: 48.902, lng: 2.47 }, + }, + pois: [ + { + name: 'Eiffel Tower', + lat: 48.8584, + lon: 2.2945, + alt: 750, + pitch: -25, + heading: 315, + buildingHeight: 150, + }, + { + name: 'Arc de Triomphe', + lat: 48.8738, + lon: 2.295, + alt: 400, + pitch: -28, + heading: 45, + buildingHeight: 25, + }, + { + name: 'Notre-Dame', + lat: 48.853, + lon: 2.3499, + alt: 400, + pitch: -25, + heading: 225, + buildingHeight: 35, + }, + { + name: 'Sacré-Cœur', + lat: 48.8867, + lon: 2.3431, + alt: 400, + pitch: -30, + heading: 180, + buildingHeight: 40, + }, + { + name: 'Louvre Pyramid', + lat: 48.8606, + lon: 2.3376, + alt: 500, + pitch: -35, + heading: 0, + buildingHeight: 10, + }, + ], + }, + dubai: { + name: 'Dubai', + groundElevation: 5, + viewBounds: { + southwest: { lat: 24.95, lng: 54.9 }, + northeast: { lat: 25.35, lng: 55.55 }, + }, + pois: [ + { + name: 'Burj Khalifa', + lat: 25.1972, + lon: 55.2744, + alt: 600, + pitch: -20, + heading: 200, + buildingHeight: 270, + }, + { + name: 'Burj Al Arab', + lat: 25.1412, + lon: 55.1853, + alt: 500, + pitch: -25, + heading: 90, + buildingHeight: 100, + }, + { + name: 'Palm Jumeirah', + lat: 25.1124, + lon: 55.139, + alt: 1200, + pitch: -40, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Dubai Frame', + lat: 25.235, + lon: 55.3003, + alt: 400, + pitch: -25, + heading: 270, + buildingHeight: 75, + }, + { + name: 'Museum of the Future', + lat: 25.2197, + lon: 55.2806, + alt: 350, + pitch: -20, + heading: 30, + buildingHeight: 35, + }, + ], + }, + dc: { + name: 'Washington DC', + groundElevation: 10, + viewBounds: { + southwest: { lat: 38.79, lng: -77.12 }, + northeast: { lat: 38.995, lng: -76.91 }, + }, + pois: [ + { + name: 'US Capitol', + lat: 38.8897, + lon: -77.0091, + alt: 550, + pitch: -25, + heading: 270, + buildingHeight: 45, + }, + { + name: 'Washington Monument', + lat: 38.8895, + lon: -77.0353, + alt: 500, + pitch: -30, + heading: 0, + buildingHeight: 85, + }, + { + name: 'Lincoln Memorial', + lat: 38.8893, + lon: -77.0502, + alt: 400, + pitch: -25, + heading: 90, + buildingHeight: 20, + }, + { + name: 'Pentagon', + lat: 38.8711, + lon: -77.0559, + alt: 800, + pitch: -40, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Jefferson Memorial', + lat: 38.8814, + lon: -77.0365, + alt: 400, + pitch: -30, + heading: 0, + buildingHeight: 25, + }, + ], + }, + tallinn: { + name: 'Tallinn', + groundElevation: 15, + viewBounds: { + southwest: { lat: 59.36, lng: 24.6 }, + northeast: { lat: 59.52, lng: 24.92 }, + }, + pois: [ + { + name: 'Viru Square', + lat: 59.4366, + lon: 24.7527, + alt: 450, + pitch: -28, + heading: 60, + buildingHeight: 25, + }, + { + name: 'Old Town / Raekoja plats', + lat: 59.4372, + lon: 24.7452, + alt: 400, + pitch: -30, + heading: 180, + buildingHeight: 20, + }, + { + name: 'Teatri väljak', + lat: 59.4344, + lon: 24.7514, + alt: 400, + pitch: -25, + heading: 220, + buildingHeight: 25, + }, + { + name: 'Port of Tallinn', + lat: 59.4445, + lon: 24.7675, + alt: 700, + pitch: -30, + heading: 90, + buildingHeight: 20, + }, + { + name: 'Ülemiste', + lat: 59.421, + lon: 24.792, + alt: 600, + pitch: -28, + heading: 45, + buildingHeight: 30, + }, + ], + }, +}; diff --git a/src/cityPresets.test.mjs b/src/cityPresets.test.mjs index dc9ef0145..2aaec446f 100644 --- a/src/cityPresets.test.mjs +++ b/src/cityPresets.test.mjs @@ -8,7 +8,8 @@ // say nothing about whether a destination is framed well. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { CITY_POIS, LOCATIONS } from './locations.js'; +import { CITY_POIS } from './cityPois.js'; +import { LOCATIONS } from './locations.js'; const DESTINATIONS = Object.entries(CITY_POIS); const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading']; diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 053217810..c9117b7d3 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -1,5 +1,6 @@ import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; +import { greatCircleMeters } from './geoDistance.js'; import { deriveWeatherEffectProfile, weatherAltitudeFactors, @@ -127,23 +128,9 @@ function compileShader(gl, type, source) { } function greatCircleM(a, b) { - if ( - ![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every( - Number.isFinite, - ) - ) - return Infinity; - const latitudeA = Cesium.Math.toRadians(a.latitude); - const latitudeB = Cesium.Math.toRadians(b.latitude); - const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude); - const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude); - const haversine = - Math.sin(latitudeDelta / 2) ** 2 + - Math.cos(latitudeA) * - Math.cos(latitudeB) * - Math.sin(longitudeDelta / 2) ** 2; - return ( - 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) + return greatCircleMeters( + { lat: a?.latitude, lon: a?.longitude }, + { lat: b?.latitude, lon: b?.longitude }, ); } @@ -187,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) { * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. */ -export class CockpitCloudEffectsController { +class CockpitCloudEffectsController { constructor(viewer, { weatherService = applicationServices.weather } = {}) { this.weatherService = weatherService; this.viewer = viewer; diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 744423c90..0b376d9fc 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,4 +1,7 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { PanelLayoutController } from './ui/panelLayoutController.js'; @@ -7,10 +10,19 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; -import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; +import { + enter as cockpitEnter, + exit as cockpitExit, + readAircraftInfo, + _adoptTrackedEntity, +} from './ui/cockpitTrackingController.js'; import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js'; import { updateContext as cockpitContext } from './ui/cockpitContext.js'; -import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js'; +import { + syncSignalLayout, + setContextCollapsed, + setSignalCollapsed, +} from './ui/cockpitLayout.js'; import { setBriefAutoRotate } from './ui/cockpitBriefing.js'; import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; @@ -18,8 +30,14 @@ import { clearSelectedLayers } from './ui/contextActions.js'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _restoreContextSession } from './ui/contextSession.js'; import { readFileSync as readRadioSource } from 'node:fs'; -const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioBindings = readRadioSource( + new URL('./ui/radioBindings.js', import.meta.url), + 'utf8', +); +const radioPresentation = readRadioSource( + new URL('./ui/radioPresentation.js', import.meta.url), + 'utf8', +); import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -28,13 +46,27 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8')); +const html = expandApplicationHtml( + fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'), +); const ui = readShellSource(); const css = readStylesheet(path.join(ROOT, 'style.css')); -const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); -const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8'); -const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); -const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); +const sceneDirector = fs.readFileSync( + path.join(ROOT, 'src', 'scenes', 'director.js'), + 'utf8', +); +const manager = fs.readFileSync( + path.join(ROOT, 'src', 'data', 'lifecycle.js'), + 'utf8', +); +const contextLayer = readLayerSource( + path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), + 'utf8', +); +const voiceActions = fs.readFileSync( + path.join(ROOT, 'src', 'voice', 'gevActions.js'), + 'utf8', +); test('Cockpit has one reset action beside its bottom exit path', () => { assert.doesNotMatch(html, /id="cockpit-quick-entry"/); @@ -43,24 +75,48 @@ test('Cockpit has one reset action beside its bottom exit path', () => { html, /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, ); - const topCenterActions = html.match(/