mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge remote-tracking branch 'upstream/main' into pr1209-merge
This commit is contained in:
commit
11aad05dc9
807 files changed
+82653
-20642
No files matched your search
+23
-4
@@ -83,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
|
||||
@@ -109,7 +115,8 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# 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
|
||||
@@ -118,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
|
||||
@@ -201,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
|
||||
@@ -221,6 +231,9 @@ AISSTREAM_API_KEY=
|
||||
# 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
|
||||
@@ -228,6 +241,7 @@ AISSTREAM_API_KEY=
|
||||
# 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 ([email protected]) for the camera feed
|
||||
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
|
||||
@@ -237,6 +251,11 @@ AISSTREAM_API_KEY=
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
## What this changes
|
||||
|
||||
<!-- One or two sentences: what changed and why. Link the issue if there is one. -->
|
||||
|
||||
## How I verified it
|
||||
|
||||
- [ ] `npm run build`
|
||||
- [ ] `npm test`
|
||||
- [ ] `npm run test:track` (dev server running)
|
||||
- [ ] Feature gate for the area I touched: <!-- e.g. `node scripts/qa-radio.mjs`, or "none applies" -->
|
||||
|
||||
<!-- Screenshots are welcome for anything visual. -->
|
||||
|
||||
## New layer checklist
|
||||
|
||||
<!--
|
||||
Fill this in if the PR adds a new data layer. Delete the whole section if it doesn't.
|
||||
See "Adding a new layer" in CONTRIBUTING.md.
|
||||
-->
|
||||
|
||||
**Screenshots**
|
||||
|
||||
<!-- At least one screenshot of the layer on the globe, plus its info card or panel if it has one. -->
|
||||
|
||||
**Recording**
|
||||
|
||||
<!--
|
||||
Preferably 15 to 30 seconds: turn the layer on, move around, open a record.
|
||||
Drag an .mp4 or .mov in here; GitHub accepts up to 10 MB on free accounts.
|
||||
If it's bigger, link to it instead.
|
||||
-->
|
||||
|
||||
**Proposed category**
|
||||
|
||||
<!--
|
||||
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
|
||||
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
|
||||
If none fits, propose a new group and say why.
|
||||
-->
|
||||
|
||||
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:
|
||||
|
||||
<!-- Bundled data must be data we have the right to redistribute. -->
|
||||
|
||||
- [ ] 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/<pr-number>-<slug>.md` fragment added, or this PR doesn't change runtime behaviour
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,10 @@ 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/
|
||||
|
||||
+734
-49
@@ -1,11 +1,707 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
|
||||
|
||||
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||
rounded the bearing without wrapping, so a view facing 359.6° read "360°"
|
||||
(Aditya, @Rohilalala, #1431).
|
||||
|
||||
- The visual styles and their presets mark their labels with the catalog-free
|
||||
`msg()`, so the share-link codec, and any server that loads it, no longer
|
||||
bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421).
|
||||
|
||||
- Keep a location search that found nothing, or failed, in the field so it can
|
||||
be corrected: the query stays, focused and selected, and a persistent
|
||||
announced message under the search row says what to do next, instead of a
|
||||
toast followed by a cleared and closed field (samehkhamis, #1420).
|
||||
|
||||
- Hosts can read share links on their server: the share-link codec and the
|
||||
`/share/<code>` address helpers are now package exports,
|
||||
`@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path`
|
||||
(samehkhamis, #1419).
|
||||
|
||||
- Share links are now short: the share button makes `/share/<code>` links
|
||||
that open on any copy of the app, follow the aircraft, military aircraft or
|
||||
satellite you were following, and fall back to its last-known position with
|
||||
a notice if it isn't reporting. A mistyped link is refused instead of
|
||||
opening a different view. Long `#v=2` links keep working (samehkhamis, #1418).
|
||||
|
||||
- Make Clear All clear the saved and shared layer selection. Layers that a
|
||||
context mode had already switched off no longer stay in the share link and
|
||||
come back on reload (samehkhamis, #1417).
|
||||
|
||||
- Restore a share link opened in an already-open tab. Pasting a link into the
|
||||
address bar, following one, or using Back/Forward now moves the camera and
|
||||
restores the style, map, panels and layers instead of changing only the URL
|
||||
(samehkhamis, #1416).
|
||||
|
||||
- Add a Scope checkbox and extend feathering to 200%, retaining scope settings
|
||||
across HUD layouts, share links and scenes. First run opens with Scope ON at
|
||||
100% feather; explicit settings and historical links retain their values. Give
|
||||
Tactical, Operator and Minimal text black backplates only when Scope is OFF,
|
||||
including vertical metadata, without changing the original HUD text color, glow or
|
||||
opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410).
|
||||
Keep title and tagline positions stable when toggling Scope, include the eye
|
||||
in the title backing, and brighten the two secondary labels only with Scope OFF.
|
||||
|
||||
- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`,
|
||||
`src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`.
|
||||
The local GeoJSON layer tests now build layers the way the catalog does, so
|
||||
they keep covering `localGeojsonCore.js`. Package exports are unchanged
|
||||
(samehkhamis, #1403).
|
||||
|
||||
- Aircraft on the ground keep getting ground-height samples after the
|
||||
computer's clock steps back, instead of waiting for it to catch up
|
||||
(quanticsoul4772, #1399).
|
||||
|
||||
- The Sharpen slider at 0 now turns the sharpen pass off instead of still
|
||||
sharpening at a low amount (quanticsoul4772, #1398).
|
||||
|
||||
- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local
|
||||
ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°"
|
||||
(Rudy Celekli, @rudycelekli, #1396).
|
||||
|
||||
- Report a fire detection's acquisition time as a UTC timestamp in
|
||||
`get_active_fires`. The tool joined FIRMS's unpadded date and time, so a
|
||||
00:45 UTC detection read "2026-01-01 45"; it now reads
|
||||
`2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain
|
||||
unknown rather than rolling into another calendar day
|
||||
(Rudy Celekli, @rudycelekli, #1395).
|
||||
|
||||
- Report Docker container health with a local HTTP check using Node, with the
|
||||
same port parsing as the launcher (John Rogers, @14traits, #1394).
|
||||
|
||||
- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now
|
||||
mounts the checkout with `:z`; without it the container could not read `/app`
|
||||
and stopped with "Cannot find module '/app/scripts/docker-start.mjs'"
|
||||
(Anıl Güvenç, @anilguvenc, #1391).
|
||||
|
||||
- Render awareness-overlay bearings and courses that round through north as
|
||||
`000°`, rather than `360°` (Rudy Celekli, #1390).
|
||||
|
||||
- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6°
|
||||
printed "N · 360°" on the local wind direction
|
||||
(Rudy Celekli, @rudycelekli, #1389).
|
||||
|
||||
- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres
|
||||
below 1000, then rounded 999.6 m up to "1000 m"
|
||||
(Rudy Celekli, @rudycelekli, #1388).
|
||||
|
||||
- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters
|
||||
below 1000, then rounded 999.6 m up to "1000 m"
|
||||
(Rudy Celekli, @rudycelekli, #1387).
|
||||
|
||||
- Show a route leg of 995 m or more as 1.0 km. The route summary and step
|
||||
cards kept meters below 1000, then rounded 996 m up to "1000 m"
|
||||
(Rudy Celekli, @rudycelekli, #1386).
|
||||
|
||||
- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step
|
||||
cards kept seconds below 60, then rounded 59.6 s up to "60 s"
|
||||
(Rudy Celekli, @rudycelekli, #1385).
|
||||
|
||||
- The HUD's AI place summary drops a reply that arrives after the camera has
|
||||
started moving, so a summary asked for before a flight no longer names the
|
||||
old place mid-flight. Cancelled or partially typed summaries retry even if the place
|
||||
context stays the same (quanticsoul4772, #1384).
|
||||
|
||||
- Reduce repeated work in aircraft detection candidates by caching unchanged
|
||||
callsign/registration text and reusing resolved fleet models. Metadata updates,
|
||||
live positions and tracked-aircraft/model handoffs remain immediate
|
||||
(samehkhamis, #1379).
|
||||
|
||||
- Increase touch-first globe zoom sensitivity to three times the default while
|
||||
retaining Cesium collision and distance limits.
|
||||
- Reuse one delayed display timestamp per civil-aircraft fleet update and cache
|
||||
extrapolation coordinate frames until their fix coordinates change. Preserve
|
||||
contact counts, movement cadence, tracked motion and ground corrections.
|
||||
|
||||
- Turning a layer off frees the memory its entities held. Cesium kept
|
||||
removed layers' draw commands in reused per-pass arrays, so Data Centers
|
||||
kept 116 MiB after it was switched off, submarine cables 74 MiB and dams
|
||||
30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367).
|
||||
|
||||
- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's
|
||||
dynamic environment map, a sky render and GPU readback per model. With
|
||||
~150 models in the scene at Atlanta on Google 3D, GPU time per frame at
|
||||
rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison
|
||||
showed no visible change (quanticsoul4772, #1366).
|
||||
|
||||
- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW
|
||||
detection was labeled 10.0 MW because the one-decimal cutoff ran before
|
||||
rounding (Rudy Celekli, @rudycelekli, #1354).
|
||||
|
||||
- Count a transit report that is 90 seconds old as one minute. The card kept
|
||||
seconds through 89s, then rounded 90s up to "Reported 2 min ago"
|
||||
(Rudy Celekli, @rudycelekli, #1353).
|
||||
|
||||
- Read a destination heading that rounds to north as 000. 359.6° was shown as
|
||||
360° on the destination bearing while the compass tape already uses 000
|
||||
(Rudy Celekli, @rudycelekli, #1352).
|
||||
|
||||
- Keep a tracked vessel's speed, course, heading and fix time when AISStream
|
||||
static data arrives; the static message only merges name, type, destination
|
||||
and IMO (rudycelekli, #1351).
|
||||
- Apply changed static metadata immediately, retaining known fields when a
|
||||
later static report omits them, without refreshing the vessel's position
|
||||
(samehkhamis, #1351).
|
||||
|
||||
- Convert NHC cyclone movement speed from miles per hour to knots, so
|
||||
`movement.speedKt` in cyclone query results is no longer about 15% high
|
||||
(rudycelekli, #1350).
|
||||
|
||||
- Label NHC cyclone classifications by their published meanings: `PTC` is a
|
||||
post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical
|
||||
systems arrive as `STS` and `STD` (rudycelekli, #1349).
|
||||
|
||||
- Wake staged weather imagery on a 150 ms timer while waiting for readiness,
|
||||
avoiding a continuous render loop and cancelling the wake when work ends
|
||||
(@nu-stats, #1345).
|
||||
|
||||
- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left"
|
||||
instead of "on the slightly left", and a lane names its side in both
|
||||
the proxy response and localized browser instructions
|
||||
(Rudy Celekli, @rudycelekli, #1343).
|
||||
|
||||
- Keep an impossible wildfire acquisition date off the detection clock, so a
|
||||
fire is not labeled with a later day it did not happen
|
||||
(Rudy Celekli, @rudycelekli, #1342).
|
||||
|
||||
- Reject impossible KartaView photo timestamps, so 31 February is unknown
|
||||
instead of rolling forward into March and passing the imagery date filter
|
||||
(Rudy Celekli, @rudycelekli, #1341).
|
||||
|
||||
- Read a hemisphere letter that is separated from its number, so a search for
|
||||
`40.7128 N 74.0060 W` flies there instead of falling through to the geocoders
|
||||
(Rudy Celekli, @rudycelekli, #1340).
|
||||
|
||||
- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and
|
||||
degrees, so longitude -122.35 reads `122°21'00.00"W` instead of
|
||||
`122°20'60.00"W` (rudycelekli, #1339).
|
||||
|
||||
- Improve touch selection of aircraft and vessels through the shared contact
|
||||
picker, reveal tracked aircraft details when opening Context, support downward
|
||||
swipe dismissal of phone drawers while preserving scrolling and controls, and
|
||||
enlarge the phone logo (samehkhamis, #1338).
|
||||
|
||||
- Evict terrain-height cache entries in fetch-time order without repeatedly
|
||||
sorting the full cache, while keeping refreshed points newest and ordering
|
||||
restored entries by their fetch time (daikaginza, #1326).
|
||||
|
||||
- Add a phone layout for viewports 720px and narrower: the globe stays full
|
||||
screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one
|
||||
panel sheet at a time, keeping attribution visible and scene STOP reachable.
|
||||
Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325).
|
||||
|
||||
- Export the KartaView and Panoramax Street Level providers, so a host composing
|
||||
its own provider list can offer the keyless providers without Mapillary
|
||||
(samehkhamis, #1324).
|
||||
- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the
|
||||
token itself (such as a same-origin proxy). Its sequence lookups go there
|
||||
through the source's transport, and the photo viewer uses MapillaryJS's
|
||||
Graph provider at that endpoint, so no client token is needed in the page
|
||||
(samehkhamis, #1324).
|
||||
|
||||
- Remove dead code across the repository: three modules nothing imports
|
||||
(`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports
|
||||
and locals nothing reads, a duplicate copy of the bundled dams data and an
|
||||
unused ship model. The app no longer builds an unused default wind layer
|
||||
and Bhote Koshi locator layer when it loads. Package exports are unchanged
|
||||
(samehkhamis, #1321).
|
||||
|
||||
- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317).
|
||||
|
||||
- Clarify shared field CSS ownership: CCTV owns calibration editor sizing,
|
||||
while Radio and Cyber supply explicit field palettes without changing the
|
||||
current appearance or native control behavior (mazy1998, #1309).
|
||||
|
||||
- Share read-only status badge presentation across CCTV source/frame labels,
|
||||
calibration provenance and Weather coverage, preserving status meanings,
|
||||
palettes, translations and announcements while preparing the view contract
|
||||
for a future Vue migration (mazy1998, #1306).
|
||||
|
||||
- Share native dropdown and numeric-field styling across Scene, CCTV, Display
|
||||
and Radio controls, with a shared CCTV number-input builder that preserves
|
||||
existing precision, validation and commit behavior (mazy1998, #1305).
|
||||
|
||||
- Point distances wrap longitude differences across the antimeridian, in voice
|
||||
proximity checks and `places_nearby` (quanticsoul4772, #1304).
|
||||
|
||||
- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a
|
||||
cached model run (quanticsoul4772, #1303).
|
||||
|
||||
- Give ordinary Scene and CCTV actions shared native buttons with consistent
|
||||
default, destructive and compact styling while preserving their existing
|
||||
behavior and specialized controls (mazy1998, #1302).
|
||||
|
||||
- Unify segmented selectors for model coverage, detection allocation, drawing
|
||||
shape, Street View and imagery comparison while preserving their existing
|
||||
behavior. Keep compact labels such as FILL and FLAT on one line, and let
|
||||
collapsed Weather release space to the open rail panel. Reserve the full mode
|
||||
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
|
||||
|
||||
- Add a shared Voice Preview and Settings panel with browser-local preferences,
|
||||
inactivity controls and accessible floating provider controls on compact layouts;
|
||||
recover inactivity after confirmed audio-less replies, release retired audio
|
||||
observers and keep cost warnings visible outside Settings
|
||||
(manjunath22466, #1299).
|
||||
|
||||
- Add Simplified Chinese (zh-CN) to the language picker, with localized dates
|
||||
and readable Chinese labels. Based on translations by @abiyeenzo (#914)
|
||||
and @EDIT-Aeterna (#979).
|
||||
(German, @gekh, #1298).
|
||||
|
||||
- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products
|
||||
(Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran
|
||||
and Yemen products and the newest incident drop) from
|
||||
github.com/s2underground/GhostMaps, with one chip per product and report
|
||||
cards on click. `/api/ghostmaps` watches the repository's branch head every
|
||||
15 s and pushes each new commit to open pages, which reload only the
|
||||
products that changed (Black Pine Defense).
|
||||
- Clear a temporary GhostMaps upstream error and notify open pages when the
|
||||
repository becomes reachable again, including when no new edition was published.
|
||||
- Release packed-image resources when a load is cancelled or fails, respect
|
||||
injected or omitted GhostMaps sources, and wait for background cache warm-up
|
||||
before removing provider test fixtures.
|
||||
- Include Simplified Chinese translations and keep the combined voice prompt
|
||||
within its existing size limit while retaining all spoken aliases.
|
||||
|
||||
- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the
|
||||
shape before the core went slim, draws its lines again instead of a silent
|
||||
blank; an overview level or any answer that is not a whole zoom shows the
|
||||
zoom-in hint (asturksever, #1288).
|
||||
|
||||
- Give ordinary sliders a shared native control and consistent track, thumb,
|
||||
value, focus and disabled styling while preserving feature behavior and
|
||||
specialized controls (mazy1998, #1287).
|
||||
|
||||
- The Street Level SINCE note sits on the value's line, in its type and a
|
||||
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
|
||||
had lost its size variable in the Street Level slim-down and rendered
|
||||
larger than the value, on a line of its own (asturksever, #1286).
|
||||
|
||||
- Keep aircraft route labels on the scheduled airport leg instead of accepting
|
||||
its distant great-circle extensions, preserving generous endpoint tolerance
|
||||
across the signed-angle branch cut (rudycelekli, #1284).
|
||||
|
||||
- Report cached CelesTrak satellite records as stale in the Data Layers row,
|
||||
preserving separate core and dense catalog provenance through refresh,
|
||||
removal and lifecycle cleanup (rudycelekli, #1283).
|
||||
|
||||
- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282).
|
||||
- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282).
|
||||
- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282).
|
||||
- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282).
|
||||
- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282).
|
||||
- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282).
|
||||
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282).
|
||||
|
||||
- Hold rendering only for animated content (asturksever, #1281).
|
||||
- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281).
|
||||
- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281).
|
||||
- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281).
|
||||
- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281).
|
||||
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281).
|
||||
- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281).
|
||||
- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281).
|
||||
|
||||
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
|
||||
|
||||
- Reserve singleflight before disk cache reads (asturksever, #1278).
|
||||
|
||||
- Bound each height request to one upstream chunk (asturksever, #1276).
|
||||
|
||||
- Preserve enable during first-load replacement (asturksever, #1275).
|
||||
- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275).
|
||||
- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275).
|
||||
|
||||
- Scan the kml root delimiter in bounded time (asturksever, #1274).
|
||||
|
||||
- Retire unavailable history and keep local trails (asturksever, #1273).
|
||||
|
||||
- Normalize geocoder bias across the antimeridian (asturksever, #1272).
|
||||
|
||||
- Reject inherited style keys during restore (asturksever, #1271).
|
||||
|
||||
- Respect provider cooldowns and bound tle groups (asturksever, #1270).
|
||||
|
||||
- Coalesce refreshes and cool down opensky failures (asturksever, #1269).
|
||||
|
||||
- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268).
|
||||
- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268).
|
||||
|
||||
- Honor Docker Compose trusted-peer and allowed-host settings from the shell
|
||||
or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway
|
||||
access to Provider Settings before Vite starts (asturksever, #1265).
|
||||
|
||||
- Prevent malformed Overpass filters from blocking the local server, and reject
|
||||
cross-site or excessive query requests before reading their bodies
|
||||
(asturksever, #1264; fixes #1248).
|
||||
- Release unread responses from the optional Google-token probe so keyless
|
||||
browser validation can settle; the Overpass gate recognizes only that probe's
|
||||
expected 404 as an unavailable capability (asturksever, #1264).
|
||||
- Make tracking arrival checks respect the aircraft update cadence while checking
|
||||
orientation on the first reveal, avoiding false failures at faster render
|
||||
rates (asturksever, #1264).
|
||||
|
||||
- The terrain-heights bounds tests no longer read the checkout's
|
||||
`.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so
|
||||
`npm test` no longer fails after the dev server has cached terrain near
|
||||
Austin (KnottyDyes, #1244).
|
||||
|
||||
- Replace the blocky JPEG sky around the globe with the 9,096 stars of the
|
||||
Yale Bright Star Catalogue, drawn as sharp points where they are at the
|
||||
scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun
|
||||
and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh,
|
||||
#1243).
|
||||
|
||||
- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest
|
||||
tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata
|
||||
released on failed or cancelled tile reads (quanticsoul4772, #1239).
|
||||
|
||||
- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV:
|
||||
~1,800 cameras read at runtime from the official MOTC 2.0 open-data table,
|
||||
with on-demand snapshots and attribution. No API key is required; set
|
||||
`CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237).
|
||||
|
||||
- Require a geocoded containment anchor for ambiguous analyst region names,
|
||||
preserve boundary provenance and cancellation safety, and retain exact-name
|
||||
precedence for natural outlines (manjunath22466, #1235).
|
||||
|
||||
- Serve the camera catalog while it refreshes. An expired catalog made every
|
||||
camera request wait for the slowest pack to reload; it is now answered at
|
||||
once while one background refresh runs, so only the first load waits. Estonia's
|
||||
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
|
||||
on about a third of loads (quanticsoul4772, #1234).
|
||||
|
||||
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
|
||||
cancel both response streams when either response fails or overflows the 4 MB
|
||||
byte cap, and deduplicate camera IDs before per-pack prioritization
|
||||
(yaka0007, #1233).
|
||||
|
||||
- Skip bike-share stations with missing, blank or invalid coordinates instead
|
||||
of placing them at coerced zero coordinates or accepting out-of-range values.
|
||||
Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232).
|
||||
|
||||
- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server
|
||||
now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's
|
||||
account page downloads) on every platform, not only through the macOS
|
||||
launcher, and no longer sends retired username/password Basic auth:
|
||||
`OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to
|
||||
set instead (quanticsoul4772, #1229).
|
||||
- Credential-file warnings omit parser excerpts and filesystem error details;
|
||||
the path example also works with the macOS launchers.
|
||||
|
||||
- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a
|
||||
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
|
||||
otherwise skipped with one log line saying where to get a key
|
||||
(quanticsoul4772, #1228).
|
||||
|
||||
- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is
|
||||
now a button that closes the card the same way ESC does; touch screens read
|
||||
DISMISS, and phones no longer hide it, so choosing a mission is not the only
|
||||
way out (KnottyDyes, #1226).
|
||||
|
||||
- Add continuous globe rotation with direction and speed controls and a globe-fit
|
||||
check before starting (manjunath22466, #1225).
|
||||
|
||||
- Open God's Eye View from Pinokio in the system browser using the supported
|
||||
`popout` menu option. The application's framing policy is unchanged
|
||||
(NukeThemAII, #1222).
|
||||
|
||||
- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI
|
||||
command, while preserving an earlier accepted deferred input
|
||||
(Manjunath, #1220).
|
||||
|
||||
- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results
|
||||
(`track_entity`, `get_entity_context`, pointer context); the voice card still
|
||||
shows them in the interface language (daikaginza, #1207).
|
||||
|
||||
- Report actual outcomes when voice enables or disables CCTV through the shared
|
||||
layer lifecycle, including failed and cancelled changes (manjunath22466,
|
||||
#1205).
|
||||
|
||||
- Draw CCTV stills at their source aspect on the monitor plane instead of
|
||||
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
|
||||
every still was widened; they are now centered and pillarboxed on the dark
|
||||
the placeholder already ends on (Danielslay86, #1201).
|
||||
|
||||
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
|
||||
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
|
||||
video camera could only be found by opening it. Stills stay cyan and the
|
||||
active camera stays amber, and focus de-emphasis fades the violet the same
|
||||
way (Danielslay86, #1200).
|
||||
|
||||
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
|
||||
launch data, and return to NOMINAL after a fresh response without discarding
|
||||
retained missions (rudycelekli, #1199).
|
||||
|
||||
- Share Launch Library request cache keys across installations refreshing in the
|
||||
same UTC minute, identify the app to the provider, and explain that standard
|
||||
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
|
||||
upgrades; existing local cache and server-only token handling are unchanged
|
||||
(rudycelekli, #1197).
|
||||
|
||||
- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
|
||||
(Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
|
||||
(Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
|
||||
BicinRivas, about 1,840 stations. Station names read underscores as word
|
||||
breaks, and station capacity counts docks rather than PBSC's geofenced area
|
||||
(ulihhhh, #1190).
|
||||
|
||||
- Keep missing launch-pad coordinates unknown so launches use the location
|
||||
fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage
|
||||
flight numbers no longer show "FLIGHT 0"
|
||||
(Rudy Celekli, @rudycelekli, #1186).
|
||||
|
||||
- Flights' ground floor works across the antimeridian. The viewer-distance
|
||||
check behind the 150 km floor radius and its copy in the photoreal mesh-floor
|
||||
sampler subtracted longitudes without wrapping, so two points 21 km apart
|
||||
across 180° measured about 38,000 km and low or grounded aircraft just across
|
||||
the line from the viewer were never floored. A contact's display corridor
|
||||
walked a segment across 180° the long way round, spending its cell budget on
|
||||
ground behind the contact, and a contact on the seam lost its cell hysteresis.
|
||||
SEARCH NEARBY SITES on a view across 180° searched Google Places around
|
||||
longitude 0, on the far side of the globe, with a radius sized from the view's
|
||||
height alone; it now searches the view (Aditya, @Rohilalala, #1173).
|
||||
|
||||
- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
|
||||
turns null, blank strings and booleans into 0 or 1, and the camera source
|
||||
list sends an unknown number as JSON null, so a source-pack camera that left
|
||||
out its pose reached the browser as zeros clamped to each field's floor —
|
||||
facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
|
||||
instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
|
||||
city's ground); a null or blank position placed the camera at 0,0. The
|
||||
server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
|
||||
blank and boolean values as missing. The same conversion also stops a
|
||||
Tallinn row with a null heading reading as a confident north-facing camera,
|
||||
a Caltrans camera with a blank elevation losing its 150 m ground prior, and
|
||||
a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
|
||||
or on the zero meridian (omerbberk, #1172).
|
||||
|
||||
- Director shot actions land on the requested shot. A seek to a shot's start
|
||||
could round a hair below it and select the end of the previous shot for some
|
||||
shot counts and durations (Aditya, @Rohilalala, #1171).
|
||||
|
||||
- Fire-perimeter queries handle outlines and holes crossing the antimeridian:
|
||||
areas enclosed near ±180° are included, areas at the opposite meridian are
|
||||
excluded, and returned perimeter coordinates stay near the dateline. Incident
|
||||
cards use the same circular longitude mean and compare multipart outline
|
||||
areas along short longitude edges, keeping the card on the largest polygon
|
||||
(Arianna Method, @ariannamethod, #1142).
|
||||
|
||||
- Scenes keep the "Scene not saved" status when a capture, update, append or
|
||||
import cannot be saved, and an unreadable saved project no longer overwrites
|
||||
the recovery checkpoint. `startScene()` resolves to its documented result,
|
||||
`afterShotId` works without an explicit scene, and `scenesRun` counts the
|
||||
distinct scenes queued (djabhi31, #1135).
|
||||
|
||||
- Label camera-tool Street View fallbacks as historical Google imagery rather
|
||||
than the current camera view; keep unknown frame provenance explicit
|
||||
(rudycelekli, #1118).
|
||||
|
||||
- Release superseded Google Street View open waits and suppress metadata
|
||||
requests whose viewer was unmounted while the service loaded (rudycelekli,
|
||||
#1117).
|
||||
|
||||
- Exclude aircraft without a reported altitude from requested altitude bands;
|
||||
unfiltered aircraft queries still retain them (rudycelekli, #1115).
|
||||
|
||||
- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain
|
||||
at once, but no longer lasts the whole session: while the fallback is on
|
||||
screen, the real provider is retried in the background (30 seconds, doubling
|
||||
to a 10-minute cap) and replaces it live once it loads. Map switches never
|
||||
wait for a retry (Aditya, @Rohilalala, #1111).
|
||||
|
||||
- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a
|
||||
spliced catalog cannot pair one satellite's elements with another's; the next
|
||||
valid set still parses (VG31OP, #1101).
|
||||
|
||||
- Overpass data whose element tags contain a failure phrase ("timed out",
|
||||
"runtime error", "too many requests" and similar, e.g. a place named "Timed
|
||||
Out Escape Rooms") is returned as data instead of being refused and putting
|
||||
the endpoint on cooldown; upstream failures are read from the remark (Aditya,
|
||||
@Rohilalala, #1097).
|
||||
|
||||
- Military aircraft keep their last heading and speed when an adsb.lol row omits
|
||||
`track` or `gs`, instead of turning north and stopping; a grounded row without
|
||||
a speed still reads 0 (Aditya, @Rohilalala, #1096).
|
||||
|
||||
- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for
|
||||
GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and
|
||||
GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK,
|
||||
#1094).
|
||||
|
||||
- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with
|
||||
the existing coordinate framing and label. Unsupported URI fields are passed
|
||||
to the remaining search providers unchanged (cj-vana, #1071).
|
||||
|
||||
- Keyless standalone startup answers an unavailable Google tile-token endpoint
|
||||
with uncached 204 instead of a browser-visible 404. Existing providers take
|
||||
precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066)
|
||||
|
||||
- On Windows, `npm run doctor` and the Pinokio install no longer print Node's
|
||||
DEP0190 warning ("Passing args to a child process with shell option true can
|
||||
lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of
|
||||
the shell command string instead of an argument array (quanticsoul4772, #1057).
|
||||
|
||||
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
|
||||
MCP client can ask which airlines, countries, aircraft types or ship types
|
||||
dominate an area, or the average altitude or speed, over every match instead
|
||||
of estimating from the returned rows. Airlines come from the ICAO designator
|
||||
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
|
||||
that report no operator; operator and type are counted only where the feed
|
||||
reports them. Ship types count by family, so AIS codes 70 to 79 are all
|
||||
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
|
||||
unknown. Altitude and speed stats cover airborne aircraft only, so parked
|
||||
aircraft do not pull the means down. Every vessel answer, `find_vessel`
|
||||
included, now reads AIS ship-type codes as their family and a destination
|
||||
with no letters as unknown (quanticsoul4772, #1056).
|
||||
|
||||
- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV
|
||||
source pack: the public DATA.GOV.HK English locations XML, frames pinned to
|
||||
`tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By
|
||||
default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads,
|
||||
prioritized across Hong Kong Island, Kowloon and the New Territories. Headings
|
||||
use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets
|
||||
the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990).
|
||||
|
||||
- Cross-site pages can no longer fill the terrain-height cache or spend the
|
||||
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
|
||||
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
|
||||
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
|
||||
the cost-bearing routes it still answers same-origin requests that arrive
|
||||
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
|
||||
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
|
||||
#975).
|
||||
|
||||
- Street Level is rebuilt around one small provider registry: Mapillary,
|
||||
KartaView, Panoramax and Google Street View share one coverage drawer and
|
||||
one photo viewer, one source lit at a time. Floating and docked panels,
|
||||
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
|
||||
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
|
||||
deleted (gekh, #974).
|
||||
|
||||
- Cross-site browser requests can no longer spend the TomTom traffic-tile
|
||||
budget through `/api/tomtom/*`. The shared same-site gate runs before status,
|
||||
cache, and upstream work; same-origin app requests and non-browser local
|
||||
callers remain supported (Sunil56224972, #973).
|
||||
|
||||
- Recent Imagery: opening DETAILS scrolls its card into view when it opens below
|
||||
the fold. The panel used to restore the previous scroll position right after
|
||||
the reveal, so the card stayed out of sight (Ian Blenke, #970).
|
||||
|
||||
- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`,
|
||||
`.gev-logs/`, and `.gev-cache/` are denied without blocking dependency
|
||||
`dist/` directories, and checkout paths containing glob metacharacters are
|
||||
escaped before building the root-anchored pattern (blowing-a-raspberry, #948).
|
||||
|
||||
- Give each Statens vegvesen camera its own ground height from Kartverket's
|
||||
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
|
||||
mountain-pass cameras no longer sit hundreds of metres below the terrain
|
||||
where neither the point-height prior nor the ground snap resolves. About 850
|
||||
of ~855 cameras get a height; the rest keep the old prior. Requests carry 20
|
||||
points, the most the API answers from its 1 m terrain model, eight at a
|
||||
time; a cold lookup takes about 6–13 s. Cameras over water use the water
|
||||
surface rather than the sea-floor depth the API returns there; for bridge
|
||||
cameras that is still below the deck. Heights are cached for good and misses
|
||||
for 6 h, so a refresh only asks about new or moved cameras. After a
|
||||
failing request (a 408, 429 or 5xx, a network error, a request over 10 s or
|
||||
an unreadable body) the lookup stops and backs off (Retry-After, else
|
||||
30 min, longer than the refresh). The lane waits at most 12 s from its
|
||||
start, and only on a cold cache; a slower first lookup leaves the cameras it
|
||||
had not reached on the flat prior until the next 15-minute refresh, and
|
||||
keeps filling the cache meanwhile.
|
||||
`CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945).
|
||||
|
||||
- Extract a reusable model-free World Packet from the existing voice
|
||||
`get_current_view_state` owner without restoring disabled layers to the
|
||||
model context. Keep the enabled-only payload, disabled-layer count, source
|
||||
provenance, action result and view-state contract; read the layer roster once
|
||||
per packet; voice retains the speech/card envelope at its own boundary.
|
||||
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||
|
||||
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
|
||||
server-held API key, microphone streaming, shared map actions and captions.
|
||||
Switching providers ends the old conversation and preserves the input mode
|
||||
(Manjunath, #872).
|
||||
|
||||
- Close the compact Radio disclosure when an adaptive tactical right-rail
|
||||
restore directly expands Context, before Space Missions can be covered.
|
||||
A repeated Context-open request also clears stale disclosure state.
|
||||
Real PanelChrome constructor wiring and the rail reflow path are covered
|
||||
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
|
||||
#804 report, asturksever's review, and quanticsoul4772's independent
|
||||
Windows diagnosis.
|
||||
|
||||
- Changelog entries are now one file per pull request. A change adds
|
||||
`changelog.d/<pull request>-<slug>.md` instead of editing the top of
|
||||
`CHANGELOG.md`, which two pull requests in flight could not share — it was the
|
||||
most frequent merge conflict in the open queue, and for six of them the only
|
||||
one. `npm run changelog` splices the directory into `CHANGELOG.md` at release
|
||||
time and empties it; `npm run changelog:check` reports the batch and changes
|
||||
nothing. Fragment text is copied through unchanged (daikaginza, #787).
|
||||
|
||||
- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
|
||||
numbers above 99999: a letter for the first two digits, skipping I and O
|
||||
(`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
|
||||
field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
|
||||
but the first were dropped as duplicates; the satellite tools read the same
|
||||
field as no number, so such a satellite could not be found by it. The layer
|
||||
and the tools now decode the field in `src/sources/tle.js`, and reject a
|
||||
malformed one (a sign, decimal point, hex, embedded space or stray letter)
|
||||
instead of reading part of it as a number. This is Alpha-5 TLE
|
||||
compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
|
||||
needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
|
||||
|
||||
- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write
|
||||
cache files and logs there while the app runs, and none of it is source, so
|
||||
the watcher now skips it; this guards against the DNS-lookup delays heavy
|
||||
cache writes have caused, though no difference was measured on current main
|
||||
(monwiss, #761).
|
||||
|
||||
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
|
||||
5-decimal point was a permanent in-memory entry, a permanent line in an
|
||||
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
|
||||
nothing limiting the rate. The point cache now holds at most 50,000 entries
|
||||
and evicts oldest-first, a cache file above
|
||||
24 MB is not read back, and the route is limited to 90 requests/minute per
|
||||
client (270 global). Evicting a point costs one refetch, never a wrong
|
||||
answer, since terrain does not move. Ordinary use is unaffected: a full
|
||||
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
|
||||
|
||||
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
|
||||
the statewide ArcGIS catalog, read a page at a time under a byte cap with
|
||||
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
|
||||
FL511's terms limit its content to individual, personal use, and any other
|
||||
use (a public deployment included) needs FDOT's express written consent, so
|
||||
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
|
||||
catalog's direction codes, and camera ids come from the image channel, since
|
||||
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
|
||||
sets the cap (Danielslay86, #605).
|
||||
|
||||
- The client's great-circle distances now come from one shared
|
||||
`src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}`
|
||||
points). The Earth radius is standardized on 6371.0088 km, so distances shift
|
||||
by about 1.4 ppm and region areas by about 2.8 ppm, and missing or
|
||||
out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749).
|
||||
|
||||
- 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
|
||||
@@ -282,8 +978,6 @@
|
||||
point. A slow render gets one bounded second frame request, while hidden,
|
||||
stale, moved-camera, invalid and oversized captures still fail closed.
|
||||
|
||||
## [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
|
||||
@@ -317,9 +1011,8 @@
|
||||
[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): from orbit dots show
|
||||
where the federated commons has pictures, and below about 60 km its
|
||||
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
|
||||
(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,
|
||||
@@ -377,10 +1070,10 @@
|
||||
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 and FOLLOW. Share links carry it in the provider switches
|
||||
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 and the legend apply to
|
||||
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.
|
||||
|
||||
@@ -419,21 +1112,16 @@
|
||||
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.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
|
||||
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
|
||||
@@ -504,6 +1192,28 @@
|
||||
needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim
|
||||
fallback order are unchanged.
|
||||
|
||||
- Voice can switch on every shipped data layer (the two scene-driven
|
||||
Bhote Koshi layers stay scene-only), including transit, Recent
|
||||
Imagery, wind, the weather layers, cyclones and mapped installations, and
|
||||
understands their spoken names ("buses", "hurricanes", "military bases").
|
||||
One voice manifest (`src/voice/layerManifest.js`) generates the tool enums
|
||||
and aliases, and a unit test fails when a new layer has no entry.
|
||||
- `analyst_query` counts up to 250,000 loaded records per layer instead of the
|
||||
first 2,000, and says when a layer went past that (`complete: false`). It
|
||||
also answers over bikeshare stations, transit vehicles, rocket launches,
|
||||
cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire
|
||||
records carry their sensor (VIIRS or MODIS).
|
||||
- Unknown fields, operators, units, value types, scope kinds and invalid
|
||||
centres are refused with the allowed values instead of answering zero or widening to anywhere. A
|
||||
layer that is off, still loading or unavailable is reported as such, not
|
||||
as a count of zero; a partly answerable query is marked `partial`.
|
||||
Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)`
|
||||
converts, and large rankings no longer sort the whole set.
|
||||
- Results carry `lat`/`lon` for each item and a `display` object with the
|
||||
scope, the feed window (earthquakes and fires cover the last 24 h) and any
|
||||
caveat for the screen. The model is asked to answer in one short phrase.
|
||||
- The weather and Recent Imagery panels can be opened by voice.
|
||||
|
||||
## [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,
|
||||
@@ -786,32 +1496,6 @@
|
||||
(`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and
|
||||
`MapSourceController.subscribe()` reports every settled map switch.
|
||||
|
||||
## Unreleased — voice reaches every layer; honest analyst answers
|
||||
|
||||
- Voice can switch on every shipped data layer (the two scene-driven
|
||||
Bhote Koshi layers stay scene-only), including transit, Recent
|
||||
Imagery, wind, the weather layers, cyclones and mapped installations, and
|
||||
understands their spoken names ("buses", "hurricanes", "military bases").
|
||||
One voice manifest (`src/voice/layerManifest.js`) generates the tool enums
|
||||
and aliases, and a unit test fails when a new layer has no entry.
|
||||
- `analyst_query` counts up to 250,000 loaded records per layer instead of the
|
||||
first 2,000, and says when a layer went past that (`complete: false`). It
|
||||
also answers over bikeshare stations, transit vehicles, rocket launches,
|
||||
cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire
|
||||
records carry their sensor (VIIRS or MODIS).
|
||||
- Unknown fields, operators, units, value types, scope kinds and invalid
|
||||
centres are refused with the allowed values instead of answering zero or widening to anywhere. A
|
||||
layer that is off, still loading or unavailable is reported as such, not
|
||||
as a count of zero; a partly answerable query is marked `partial`.
|
||||
Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)`
|
||||
converts, and large rankings no longer sort the whole set.
|
||||
- Results carry `lat`/`lon` for each item and a `display` object with the
|
||||
scope, the feed window (earthquakes and fires cover the last 24 h) and any
|
||||
caveat for the screen. The model is asked to answer in one short phrase.
|
||||
- The weather and Recent Imagery panels can be opened by voice.
|
||||
|
||||
## Unreleased — local receiver feeds
|
||||
|
||||
### Local receiver feeds
|
||||
|
||||
- The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder
|
||||
@@ -954,7 +1638,6 @@
|
||||
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
|
||||
|
||||
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
|
||||
|
||||
- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
|
||||
wildfire incident perimeters as ground-clamped polygons with a
|
||||
containment-colored fire line, refreshed every 5 minutes from the public
|
||||
@@ -1231,6 +1914,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
### Other changes
|
||||
|
||||
|
||||
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
|
||||
|
||||
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
|
||||
@@ -2039,3 +2723,4 @@ represent previously published GitHub Releases.
|
||||
## [0.1.0] — 2026-02-09
|
||||
|
||||
- Initial project version.
|
||||
|
||||
+35
-10
@@ -54,9 +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. Five have an `npm run` alias: `qa:map-source-tray`,
|
||||
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
|
||||
`qa:transit`.
|
||||
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
|
||||
@@ -66,11 +65,10 @@ gate for the feature you changed.
|
||||
|
||||
> **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, and against the built preview
|
||||
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
|
||||
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
|
||||
> server and a browser. Include the applicable local run in your PR's
|
||||
> validation evidence.
|
||||
> production build, plus a Windows onboarding job. It runs neither
|
||||
> `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
|
||||
|
||||
@@ -110,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/<family>/`, 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/<family>/`, 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).
|
||||
@@ -118,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/<layer>.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/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
|
||||
- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers/<family>/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.
|
||||
@@ -155,6 +153,33 @@ ownership and adoption process.
|
||||
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/<pr-number>-<slug>.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
|
||||
|
||||
|
||||
+19
-4
@@ -20,10 +20,10 @@ 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 and density points, 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 |
|
||||
| **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 |
|
||||
@@ -38,6 +38,7 @@ How to read this:
|
||||
| **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)" |
|
||||
| **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 |
|
||||
@@ -48,6 +49,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) |
|
||||
@@ -62,7 +64,9 @@ How to read this:
|
||||
| **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" |
|
||||
| **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` |
|
||||
@@ -110,6 +114,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
**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.
|
||||
@@ -152,7 +157,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **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/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
@@ -162,15 +167,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/<cameraId>`, 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/<cameraId>/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 `[email protected]` 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/<uuid>/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 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 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.
|
||||
- **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.
|
||||
@@ -198,6 +206,8 @@ 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) |
|
||||
|
||||
@@ -270,6 +280,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
|
||||
|
||||
@@ -39,6 +39,11 @@ COPY --chown=node:node . .
|
||||
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.
|
||||
|
||||
@@ -111,13 +111,15 @@ 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).
|
||||
|
||||
**Pinokio opens but the globe is very choppy?** Before treating that as a GPU
|
||||
**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
|
||||
@@ -177,6 +179,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in,
|
||||
|
||||
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.
|
||||
|
||||
<details>
|
||||
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
|
||||
|
||||
@@ -197,6 +209,8 @@ 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)).
|
||||
|
||||
</details>
|
||||
@@ -309,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.
|
||||
@@ -329,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:
|
||||
@@ -377,15 +399,15 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🌍 **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** | ~4,000 public cameras (shared cap) 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) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). 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, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📷 **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, 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 | 🟢 |
|
||||
| 🌬️ **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 | 🟢 |
|
||||
@@ -421,9 +443,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**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), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. 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 and legend 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 under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and 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**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), 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 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.
|
||||
**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.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`.
|
||||
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
|
||||
|
||||
@@ -474,7 +496,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
|
||||
@@ -527,9 +549,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.
|
||||
|
||||
+14
-2
@@ -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.
|
||||
@@ -29,7 +34,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
|
||||
|
||||
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.
|
||||
|
||||
@@ -69,11 +74,13 @@ 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/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **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 `/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 `<img>`/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 `<img>`/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 `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
@@ -98,6 +105,11 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
||||
`compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN
|
||||
client arrives from the same gateway address, which hands them Provider
|
||||
Settings too.** Treat publishing beyond loopback as the LAN opt-in above.
|
||||
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from
|
||||
the shell or project `.env` before the launcher runs. Set trusted peers to
|
||||
`none` when widening the publish: it becomes an explicit empty list before
|
||||
Vite loads configuration, so the gateway cannot regain Provider Settings
|
||||
access. Unset or blank values preserve automatic gateway discovery.
|
||||
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
||||
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
|
||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||
|
||||
+105
-2
@@ -1,9 +1,29 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { applicationHtmlPlugin } from './application-html.js';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
|
||||
/**
|
||||
* The repository root (this file lives at `<root>/build/vite.js`), as a
|
||||
* forward-slash path for glob matching. Vite's `fs.deny` patterns are
|
||||
* matched against the full absolute file path, and a bare `**\/dist/**`
|
||||
* matches a `dist` directory at *any* depth — including dependencies that
|
||||
* ship their own, such as `node_modules/vite/dist/client/client.mjs` (the
|
||||
* dev client itself). Anchoring to this repository's root denies only the
|
||||
* project's own build output.
|
||||
*/
|
||||
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
export function escapeFsDenyPath(path) {
|
||||
return path.replace(/[()[\]{}*?!+@|]/g, '\\$&');
|
||||
}
|
||||
|
||||
const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT);
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every document the dev/preview server serves.
|
||||
* No inline script is permitted, and the only foreign script origins are the
|
||||
@@ -47,6 +67,75 @@ export const BROWSER_HEADERS = Object.freeze({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every `/api` response. API routes return data,
|
||||
* never a document, so a response opened directly in a tab gets no origin and
|
||||
* can load nothing.
|
||||
*/
|
||||
export const API_CSP = "default-src 'none'; sandbox";
|
||||
|
||||
/** Response headers every `/api` response carries. */
|
||||
export const API_HEADERS = Object.freeze({
|
||||
'Content-Security-Policy': API_CSP,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether a request reaches an `/api` mount, by the router's own rule: the
|
||||
* prefix compared without case and followed by the end, `/` or `.`. The router
|
||||
* reads an absolute-form target's path without resolving dot segments, so the
|
||||
* path is taken here the same way (scheme and authority dropped, backslashes as
|
||||
* slashes), alongside the raw and the fully parsed path; any match counts.
|
||||
*/
|
||||
function isApiPath(url) {
|
||||
const raw = String(url || '/');
|
||||
const target = raw
|
||||
.trim()
|
||||
.split(/[?#]/, 1)[0]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^[a-z][a-z0-9+.-]*:(?:\/\/[^/]*)?/i, '');
|
||||
let parsed = '';
|
||||
try {
|
||||
parsed = new URL(raw, 'http://localhost').pathname;
|
||||
} catch {
|
||||
/* the unparsed paths still decide */
|
||||
}
|
||||
return [raw.split(/[?#]/, 1)[0], target, parsed].some((path) =>
|
||||
/^\/api(?:$|[/.])/i.test(path),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that sets API_HEADERS on every API response before any other
|
||||
* middleware runs, so success, error, partial, refused and unknown-route
|
||||
* answers all carry them. Vite's `server.headers` and `preview.headers` only
|
||||
* reach the documents and assets Vite serves itself. A route may still add
|
||||
* headers; a route's own Content-Security-Policy is sent as an additional
|
||||
* policy, which can only restrict further, never replace this one.
|
||||
*/
|
||||
export function apiResponseHeadersPlugin() {
|
||||
const install = (server) => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!isApiPath(req.url)) return next();
|
||||
for (const [name, value] of Object.entries(API_HEADERS))
|
||||
res.setHeader(name, value);
|
||||
const setHeader = res.setHeader.bind(res);
|
||||
res.setHeader = (name, value) =>
|
||||
String(name).toLowerCase() === 'content-security-policy' &&
|
||||
String(value) !== API_CSP
|
||||
? setHeader(name, `${API_CSP}, ${value}`)
|
||||
: setHeader(name, value);
|
||||
next();
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'api-response-headers',
|
||||
enforce: 'pre',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build browser assets with explicit inputs; never load environment or providers. */
|
||||
export function createBrowserViteConfig({
|
||||
plugins = [],
|
||||
@@ -61,7 +150,9 @@ export function createBrowserViteConfig({
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [
|
||||
// First, so no provider route answers a Host the server does not allow.
|
||||
// First, so every API response, including a Host refusal, carries them.
|
||||
apiResponseHeadersPlugin(),
|
||||
// Next, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
applicationHtmlPlugin(),
|
||||
@@ -94,7 +185,19 @@ export function createBrowserViteConfig({
|
||||
// build/allowedHosts.js); IP addresses are always accepted.
|
||||
allowedHosts: [...allowedHosts],
|
||||
fs: {
|
||||
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
|
||||
deny: [
|
||||
'.env',
|
||||
'.env.*',
|
||||
'*.{crt,pem}',
|
||||
'**/.git/**',
|
||||
'**/ENVIRONMENT',
|
||||
'**/.gev-logs/**',
|
||||
'**/.gev-cache/**',
|
||||
// Root-anchored: a bare '**/dist/**' would also deny every
|
||||
// dependency's own dist/ (e.g. node_modules/vite/dist/client),
|
||||
// breaking /@vite/client and other dev-client assets.
|
||||
`${REPO_ROOT_GLOB}/dist/**`,
|
||||
],
|
||||
},
|
||||
// These headers protect the document containing Provider Settings and
|
||||
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
- On Windows, `npm run doctor` and the Pinokio install no longer print Node's
|
||||
DEP0190 warning ("Passing args to a child process with shell option true can
|
||||
lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of
|
||||
the shell command string instead of an argument array (quanticsoul4772, #1057).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with
|
||||
the existing coordinate framing and label. Unsupported URI fields are passed
|
||||
to the remaining search providers unchanged (cj-vana, #1071).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for
|
||||
GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and
|
||||
GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK,
|
||||
#1094).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Military aircraft keep their last heading and speed when an adsb.lol row omits
|
||||
`track` or `gs`, instead of turning north and stopping; a grounded row without
|
||||
a speed still reads 0 (Aditya, @Rohilalala, #1096).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Overpass data whose element tags contain a failure phrase ("timed out",
|
||||
"runtime error", "too many requests" and similar, e.g. a place named "Timed
|
||||
Out Escape Rooms") is returned as data instead of being refused and putting
|
||||
the endpoint on cooldown; upstream failures are read from the remark (Aditya,
|
||||
@Rohilalala, #1097).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a
|
||||
spliced catalog cannot pair one satellite's elements with another's; the next
|
||||
valid set still parses (VG31OP, #1101).
|
||||
@@ -1,5 +0,0 @@
|
||||
- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain
|
||||
at once, but no longer lasts the whole session: while the fallback is on
|
||||
screen, the real provider is retried in the background (30 seconds, doubling
|
||||
to a 10-minute cap) and replaces it live once it loads. Map switches never
|
||||
wait for a retry (Aditya, @Rohilalala, #1111).
|
||||
@@ -1,2 +0,0 @@
|
||||
- Exclude aircraft without a reported altitude from requested altitude bands;
|
||||
unfiltered aircraft queries still retain them (rudycelekli, #1115).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Release superseded Google Street View open waits and suppress metadata
|
||||
requests whose viewer was unmounted while the service loaded (rudycelekli,
|
||||
#1117).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Label camera-tool Street View fallbacks as historical Google imagery rather
|
||||
than the current camera view; keep unknown frame provenance explicit
|
||||
(rudycelekli, #1118).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Scenes keep the "Scene not saved" status when a capture, update, append or
|
||||
import cannot be saved, and an unreadable saved project no longer overwrites
|
||||
the recovery checkpoint. `startScene()` resolves to its documented result,
|
||||
`afterShotId` works without an explicit scene, and `scenesRun` counts the
|
||||
distinct scenes queued (djabhi31, #1135).
|
||||
@@ -1,6 +0,0 @@
|
||||
- Fire-perimeter queries handle outlines and holes crossing the antimeridian:
|
||||
areas enclosed near ±180° are included, areas at the opposite meridian are
|
||||
excluded, and returned perimeter coordinates stay near the dateline. Incident
|
||||
cards use the same circular longitude mean and compare multipart outline
|
||||
areas along short longitude edges, keeping the card on the largest polygon
|
||||
(Arianna Method, @ariannamethod, #1142).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Director shot actions land on the requested shot. A seek to a shot's start
|
||||
could round a hair below it and select the end of the previous shot for some
|
||||
shot counts and durations (Aditya, @Rohilalala, #1171).
|
||||
@@ -1,13 +0,0 @@
|
||||
- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
|
||||
turns null, blank strings and booleans into 0 or 1, and the camera source
|
||||
list sends an unknown number as JSON null, so a source-pack camera that left
|
||||
out its pose reached the browser as zeros clamped to each field's floor —
|
||||
facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
|
||||
instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
|
||||
city's ground); a null or blank position placed the camera at 0,0. The
|
||||
server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
|
||||
blank and boolean values as missing. The same conversion also stops a
|
||||
Tallinn row with a null heading reading as a confident north-facing camera,
|
||||
a Caltrans camera with a blank elevation losing its 150 m ground prior, and
|
||||
a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
|
||||
or on the zero meridian (omerbberk, #1172).
|
||||
@@ -1,10 +0,0 @@
|
||||
- Flights' ground floor works across the antimeridian. The viewer-distance
|
||||
check behind the 150 km floor radius and its copy in the photoreal mesh-floor
|
||||
sampler subtracted longitudes without wrapping, so two points 21 km apart
|
||||
across 180° measured about 38,000 km and low or grounded aircraft just across
|
||||
the line from the viewer were never floored. A contact's display corridor
|
||||
walked a segment across 180° the long way round, spending its cell budget on
|
||||
ground behind the contact, and a contact on the seam lost its cell hysteresis.
|
||||
SEARCH NEARBY SITES on a view across 180° searched Google Places around
|
||||
longitude 0, on the far side of the globe, with a radius sized from the view's
|
||||
height alone; it now searches the view (Aditya, @Rohilalala, #1173).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Keep missing launch-pad coordinates unknown so launches use the location
|
||||
fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage
|
||||
flight numbers no longer show "FLIGHT 0"
|
||||
(Rudy Celekli, @rudycelekli, #1186).
|
||||
@@ -1,6 +0,0 @@
|
||||
- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
|
||||
(Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
|
||||
(Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
|
||||
BicinRivas, about 1,840 stations. Station names read underscores as word
|
||||
breaks, and station capacity counts docks rather than PBSC's geofenced area
|
||||
(ulihhhh, #1190).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add Traditional Chinese (zh-Hant) with language selection and localized
|
||||
Chinese readouts: the Simplified Chinese catalog written in Traditional
|
||||
characters, based on translations by @abiyeenzo (#914) and @EDIT-Aeterna
|
||||
(#979) (German, @gekh, #1430).
|
||||
@@ -1,5 +0,0 @@
|
||||
- The client's great-circle distances now come from one shared
|
||||
`src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}`
|
||||
points). The Earth radius is standardized on 6371.0088 km, so distances shift
|
||||
by about 1.4 ppm and region areas by about 2.8 ppm, and missing or
|
||||
out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749).
|
||||
@@ -1,5 +0,0 @@
|
||||
- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write
|
||||
cache files and logs there while the app runs, and none of it is source, so
|
||||
the watcher now skips it; this guards against the DNS-lookup delays heavy
|
||||
cache writes have caused, though no difference was measured on current main
|
||||
(monwiss, #761).
|
||||
@@ -1,11 +0,0 @@
|
||||
- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
|
||||
numbers above 99999: a letter for the first two digits, skipping I and O
|
||||
(`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
|
||||
field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
|
||||
but the first were dropped as duplicates; the satellite tools read the same
|
||||
field as no number, so such a satellite could not be found by it. The layer
|
||||
and the tools now decode the field in `src/sources/tle.js`, and reject a
|
||||
malformed one (a sign, decimal point, hex, embedded space or stray letter)
|
||||
instead of reading part of it as a number. This is Alpha-5 TLE
|
||||
compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
|
||||
needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
|
||||
@@ -1,7 +0,0 @@
|
||||
- Changelog entries are now one file per pull request. A change adds
|
||||
`changelog.d/<pull request>-<slug>.md` instead of editing the top of
|
||||
`CHANGELOG.md`, which two pull requests in flight could not share — it was the
|
||||
most frequent merge conflict in the open queue, and for six of them the only
|
||||
one. `npm run changelog` splices the directory into `CHANGELOG.md` at release
|
||||
time and empties it; `npm run changelog:check` reports the batch and changes
|
||||
nothing. Fragment text is copied through unchanged (daikaginza, #787).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Recent Imagery: opening DETAILS scrolls its card into view when it opens below
|
||||
the fold. The panel used to restore the previous scroll position right after
|
||||
the reveal, so the card stayed out of sight (Ian Blenke, #970).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Cross-site browser requests can no longer spend the TomTom traffic-tile
|
||||
budget through `/api/tomtom/*`. The shared same-site gate runs before status,
|
||||
cache, and upstream work; same-origin app requests and non-browser local
|
||||
callers remain supported (Sunil56224972, #973).
|
||||
+9
-1
@@ -5,6 +5,11 @@ services:
|
||||
# lands in the bind-mounted repo, so run as you, not root. UID/GID are
|
||||
# unset on Docker Desktop, where the 1000 fallback is ignored anyway.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
# Forward explicit shell/.env policy before the launcher discovers a gateway.
|
||||
# Unset values stay unset so the default loopback-only install still works.
|
||||
environment:
|
||||
- GEV_KEY_SETUP_TRUSTED_PEERS
|
||||
- GEV_ALLOWED_HOSTS
|
||||
ports:
|
||||
# Publish to loopback only. Docker's default ("4173:4173") would bind
|
||||
# every host interface, which quietly turns the LAN warning in the README
|
||||
@@ -17,5 +22,8 @@ services:
|
||||
# UI land in the .env on your host instead of vanishing with the
|
||||
# container. node_modules stays in the image: sharp and friends are
|
||||
# compiled for Linux and must not be shadowed by a host install.
|
||||
- .:/app
|
||||
# `:z` relabels the checkout for containers on SELinux hosts (Fedora,
|
||||
# RHEL); without it the container cannot read /app at all and Node
|
||||
# reports the launcher as "Cannot find module". Elsewhere it is ignored.
|
||||
- .:/app:z
|
||||
- /app/node_modules
|
||||
+49
-11
@@ -79,17 +79,13 @@ moves it. Do not label all ingestion modules platform-independent by filename.
|
||||
|
||||
| Retained entry | Owner and current reason |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` |
|
||||
| `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately |
|
||||
| `src/data/<layer>.js` | Legacy default instances; new assembly calls layer factories with supplied sources |
|
||||
| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly |
|
||||
| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` |
|
||||
| `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances |
|
||||
| `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports |
|
||||
|
||||
The two exact standalone-import exceptions are `src/ui.js` to
|
||||
`src/standalone/catalog.js` and `server/providers/local.js` to
|
||||
`server/standalone/key-setup.js`. They do not authorize new compatibility
|
||||
The one exact standalone-import exception is `server/providers/local.js` to
|
||||
`server/standalone/key-setup.js`. It does not authorize new compatibility
|
||||
back-edges. Voice actions use plain `src/data/feedState.js`, not the manager
|
||||
facade. Settings filesystem hardening belongs in
|
||||
`server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged.
|
||||
@@ -237,7 +233,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()`
|
||||
removes owned listeners and cancels pending work without changing saved state
|
||||
or moving focus. Call it before removing or replacing the controls.
|
||||
|
||||
`src/ui.js` retains panel layout, persistence, share restoration and application
|
||||
`src/ui/applicationShell.js` retains panel layout, persistence, share restoration and application
|
||||
reactions to state changes. Map Source selection and Location draft cleanup are
|
||||
provided through callbacks. The component imports no globe, data, application
|
||||
or server modules. Package checks and scoped formatting cover this entry.
|
||||
@@ -356,8 +352,8 @@ drag or final change. Feature adapters own data mapping and service subscription
|
||||
generic modules own DOM/listeners/timers and release them in `destroy()`.
|
||||
|
||||
Keep persistence with panel chrome: collapse uses
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
|
||||
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
|
||||
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
|
||||
Do not rename existing
|
||||
ids or reset persistence versions when adding a body. The WEATHER adapter is
|
||||
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
|
||||
@@ -374,6 +370,15 @@ renderer, persistence or services. The facade retains panel visibility, share
|
||||
restore claims and render scheduling. Both controls are destroyed before the
|
||||
facade's asynchronous teardown can yield.
|
||||
|
||||
## Ordinary action buttons
|
||||
|
||||
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
|
||||
presentation and sharing-toolbar owners. It receives translated text and explicit
|
||||
presentation/accessibility properties. Feature controllers own action dispatch,
|
||||
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
|
||||
surface and preserve IDs and layout hooks. The helper imports no application,
|
||||
renderer, storage or services; it belongs to the consuming package closures.
|
||||
|
||||
## Display controls
|
||||
|
||||
`ui/display` owns Display button, selector and slider subscriptions. It receives
|
||||
@@ -461,8 +466,8 @@ release listeners; pending action feedback is limited to its current owner.
|
||||
## UI assembly and styles
|
||||
|
||||
`ui/shell` assembles controls from supplied existing layer, navigation, terrain,
|
||||
rendering, HUD and share operations. `src/standalone/ui.js` provides the running
|
||||
application's instances; `src/ui.js` remains the compatibility entry. The shell
|
||||
rendering, HUD and share operations. `src/standalone/controls.js` provides the running
|
||||
application's instances. The shell
|
||||
imports no standalone bootstrap or concrete live layer implementation.
|
||||
Panel layout, position/drag, notices, recording and deferred UI work have separate
|
||||
owners with synchronous cleanup. Existing scene, share and HUD engines retain
|
||||
@@ -593,6 +598,10 @@ state subscriptions retain their existing event contract.
|
||||
tracking handoff. It reads Cockpit admission and uses supplied tracking operations.
|
||||
- `ui/share-restoration` owns the initial restore transaction, layer coordinator,
|
||||
status notices and gesture/timer cleanup.
|
||||
- `share/view-codec` is the share-link codec (`decodeShareView`,
|
||||
`encodeShareView`). It is pure (no DOM, no Cesium), so a host's server can
|
||||
read a `/share/<code>` link, for example to describe it in link previews.
|
||||
`share/link-path` reads and builds the `/share/<code>` address itself.
|
||||
- `ui/visual-settings` owns style preferences, detection overrides, display inputs
|
||||
and IR cleanup. Its engine services and panel operations are explicit.
|
||||
- `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary
|
||||
@@ -729,3 +738,32 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering,
|
||||
exact-bound retry and rendering stay in the layer. Legacy cache saturation is
|
||||
decoded in the source. Source and geometry modules remain independent of Cesium,
|
||||
DOM and platform middleware.
|
||||
|
||||
### Native form fields
|
||||
|
||||
`src/ui/formField.js` constructs native numeric inputs without listeners or
|
||||
application state. Static selects and number inputs use the same `gev-field`
|
||||
surface, with explicit compact and inline densities. Feature owners retain
|
||||
option identity/order, bounds, parsing, draft values, input/change timing and
|
||||
teardown. CCTV effective-pose conversion and tuner/device operations remain
|
||||
feature responsibilities. A Vue replacement can reuse this contract and CSS;
|
||||
it replaces DOM construction, rather than adding a second state owner.
|
||||
|
||||
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
|
||||
palettes use `--gev-field-background`, `--gev-field-border-color`,
|
||||
`--gev-field-color` and `--gev-field-disabled-opacity`. Radio retains its own
|
||||
compact density and arrow space. CCTV owns calibration editor width in
|
||||
`cctv.css`; the shared inline density does not impose a feature-specific width.
|
||||
Cyber supplies a shared-field palette explicitly and keeps its legacy native
|
||||
field rules separate, so Radio's palette remains an intentional exception.
|
||||
|
||||
### Read-only status badges
|
||||
|
||||
`src/ui/statusBadge.js` constructs/updates ordinary badges without listeners,
|
||||
status inference or live-region policy. It consumes translated text and controlled
|
||||
visibility; appearance variants share CSS rather than mapping machine states to
|
||||
universal severity. CCTV owns frame/source and calibration rules and their data
|
||||
attributes, while Weather owns coverage labels. The keyed rail-card owner keeps
|
||||
node identity, header focus, disclosure and teardown. The shared adapter never
|
||||
creates another state store or announces all card updates. A Vue view can replace
|
||||
this adapter and retain these presentation inputs and styles.
|
||||
+658
-121
File diff suppressed because it is too large.
Load diff
@@ -53,9 +53,8 @@ Cesium installation shared with its viewer; it must not bundle separate copies.
|
||||
`gods-eye-view/infrastructure/geojson` exports the lower-level
|
||||
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
|
||||
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
|
||||
The standalone app's `src/data/localGeojson.js` keeps its original single-argument
|
||||
factory and supplies its existing functions. Other consumers should use the package
|
||||
exports, which do not import standalone application globals.
|
||||
Consumers use the package exports, which do not import standalone application
|
||||
globals.
|
||||
|
||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
|
||||
|
||||
@@ -57,9 +57,8 @@ Related keys (current versions):
|
||||
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
|
||||
`'1'` closed, absent means the panel's own default. A view opened from a share
|
||||
link ignores the stored value entirely.
|
||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
|
||||
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
|
||||
the panel header, docks the panel again.
|
||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
|
||||
writes none.
|
||||
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# Share-link format
|
||||
|
||||
A share link restores a view: where the camera is, what it follows, the visual
|
||||
style, data layers and panel layout. There are two forms.
|
||||
|
||||
- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by
|
||||
`src/sharelink.js`. The address bar follows the live view in this form, and
|
||||
links already in the wild keep working; the long form is never removed.
|
||||
- **Short form** — `<app>/share/<code>`, where `<app>` is wherever the app is
|
||||
served (an origin's root, or a base path) and `<code>` is encoded and
|
||||
decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and
|
||||
needs no server. Hosts import it as `@gods-eye-view/core/share/view-codec`
|
||||
(a server can read a code to describe the view in a link preview). The share button makes this form. The same code opens on
|
||||
any copy of the app. `src/share/shareLinkPath.js` reads and builds the
|
||||
address, and `src/share/legacyShareState.js` converts between the long
|
||||
form's parsed state and the code's state.
|
||||
|
||||
The same view in both forms (a real session's link):
|
||||
|
||||
| Form | Characters |
|
||||
| ----- | ----------------- |
|
||||
| Long | 302 (the hash) |
|
||||
| Short | 39 (the `<code>`) |
|
||||
|
||||
For example, a camera-only place view is a 24-character code (31 with its
|
||||
created-at time).
|
||||
Following an aircraft is 42–50: 42 when the link is made from the app's own
|
||||
follow camera with the fix taken as the link is made, 44 with a fix four
|
||||
seconds older, and 50 after orbiting to another heading and pitch. A view in
|
||||
any other style that keeps the style's own look adds only the style (two
|
||||
bytes): the 42-character followed aircraft in Noir is 44.
|
||||
|
||||
## Opening a short link
|
||||
|
||||
At start-up the app reads `<code>` from an address whose path ends in
|
||||
`share/<code>` (a host can pass the code instead, as `initialShareCode` to
|
||||
`createApplicationControls`). A code that decodes runs the same restore as a
|
||||
long link; once the app next writes the address, `/share/<code>` is replaced
|
||||
by the app's own address and the long form, without a new history entry, so
|
||||
a reload follows the live view. A code that is rejected restores nothing: the
|
||||
app starts as usual and says the link can't be opened.
|
||||
|
||||
A thing view turns on its entity's layer and follows the entity by id, using
|
||||
the same restore as a long link's followed flight or satellite (and its
|
||||
expiry windows); a vessel is selected once the vessel feed has it. Opening is
|
||||
best effort: until the entity is found, and if it is not found in time, the
|
||||
camera is where the link was made, looking at the entity's last-known fix,
|
||||
and the app says that the entity isn't reporting and when the fix was taken.
|
||||
The share button makes a thing view while the camera follows an aircraft,
|
||||
military aircraft or satellite. A selected vessel is not followed (the camera
|
||||
stays where the user puts it), so a link made then is a place view; vessel
|
||||
thing views open, but the app does not make them yet.
|
||||
|
||||
## Payload
|
||||
|
||||
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
|
||||
|
||||
1. one **version byte**, currently `1`;
|
||||
2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type`
|
||||
followed by its value;
|
||||
3. one **check byte**: the CRC-8 of everything before it.
|
||||
|
||||
| Wire type | Value |
|
||||
| --------- | ----------------------------------------------------------- |
|
||||
| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 |
|
||||
| 1 fixed64 | 8 bytes (not used by v1; skipped) |
|
||||
| 2 LEN | varint length, then that many bytes (text or a sub-message) |
|
||||
| 5 fixed32 | 4 bytes (not used by v1; skipped) |
|
||||
|
||||
Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable
|
||||
ASCII. Fields are written in ascending order; the decoder accepts any order.
|
||||
|
||||
A field whose value equals its default is **omitted**. The decoder fills every
|
||||
omitted field from the same defaults table the encoder reads (for a
|
||||
style-dependent field, the decoded style's defaults), so an empty
|
||||
payload (code `AQc`: the version byte and its check byte) is the all-default
|
||||
view.
|
||||
|
||||
### Check byte
|
||||
|
||||
The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial
|
||||
value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the
|
||||
ASCII digits `123456789` is `0xF4`). A decoder that computes a different value
|
||||
rejects the code (`checksum`) before reading any field, so a mistyped code
|
||||
never opens a different view. A degree-8 CRC catches every error burst of up
|
||||
to 8 bits; one base64url character is 6 bits, so **every single-character
|
||||
typo is rejected** (a test tries every substitution at every position). Two
|
||||
swapped neighbours, a dropped or an extra character are caught with a
|
||||
probability of about 255 in 256 (many of them already fail as base64url).
|
||||
|
||||
## Field registry (top level)
|
||||
|
||||
| # | State key | Type | Default | Encoding |
|
||||
| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- |
|
||||
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
|
||||
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
|
||||
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
|
||||
| 4 | `alt` | sint | 800 | whole metres |
|
||||
| 5 | `heading` | angle | 0 | whole degrees, see angles |
|
||||
| 6 | `pitch` | sint | −35 | whole degrees |
|
||||
| 7 | `roll` | angle | 0 | whole degrees |
|
||||
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||
| 9 | booleans | flags | 0 | bit set when the value differs from default |
|
||||
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
|
||||
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
|
||||
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
||||
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber |
|
||||
| 17 | `bloomIntensity` | uint | style | |
|
||||
| 18 | `bloomVersion` | uint | 2 | |
|
||||
| 19 | `sharpenIntensity` | uint | style | |
|
||||
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
|
||||
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
|
||||
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
|
||||
| 23 | `detectionFadePct` | uint | style | 0–40 |
|
||||
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
|
||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||
|
||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||
so v1 never writes it; a later view kind writes its code.
|
||||
|
||||
Enum codes are the list position (0, 1, 2…). A name not in the list is written
|
||||
as LEN text in the same field, so the app can add a style or map before this
|
||||
table learns its code. A new style's settings are encoded against its own
|
||||
defaults, while a decoder that does not know the style uses Normal's; adding a
|
||||
style also extends the pinned style-defaults table.
|
||||
|
||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||
|
||||
### Style defaults
|
||||
|
||||
A default marked **style** is what the view's style applies when it is
|
||||
selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's
|
||||
first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`),
|
||||
with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's
|
||||
shader parameters at the preset's values, else the shader's own. The encoder
|
||||
compares against the defaults of the view's style, and the decoder fills
|
||||
omitted settings from the defaults of the style it decoded (an unknown style
|
||||
uses Normal's). So a view that keeps its style's look costs its camera and
|
||||
style alone. (A recipient-side preference the app remembers, such as the
|
||||
detection allocation, is not a style default: a sender who changed it pays
|
||||
for it.) In v1:
|
||||
|
||||
| Setting | Every style |
|
||||
| ------------------------- | ------------- |
|
||||
| bloom, intensity | off, 0 |
|
||||
| sharpen, intensity | on, 49 |
|
||||
| HUD visible, variant | on, tactical |
|
||||
| detection mode, density | DENSE, 75 |
|
||||
| allocation, fade, outside | ELASTIC, 7, 1 |
|
||||
| celestial ring | off |
|
||||
|
||||
| Style | Shader parameters (field 27 defaults) |
|
||||
| ------------ | --------------------------------------------------------------- |
|
||||
| normal | none |
|
||||
| retro | pixelation 1, distortion 0, instability 0.42 |
|
||||
| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 |
|
||||
| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 |
|
||||
| anime | saturation 1, edgeThick 0.5 |
|
||||
| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 |
|
||||
| snow | density 0.6, wind 0.5 |
|
||||
|
||||
These are frozen like every other default: the codec reads them from the
|
||||
app's tables today, and a test pins them, so if a table moves the old values
|
||||
are frozen in the codec rather than changing what shared links mean.
|
||||
|
||||
### Precision
|
||||
|
||||
| Value | Stored as | Matches the long form |
|
||||
| --------------------------- | ------------------------- | --------------------- |
|
||||
| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` |
|
||||
| Altitude, range | whole metres | `Math.round` |
|
||||
| Heading, pitch, roll | whole degrees | `Math.round` |
|
||||
| Intensities and percentages | integers | `Math.round` |
|
||||
| Style parameters | hundredths after clamping | `sp` (× 100) |
|
||||
| Created-at and observed-at | whole seconds | `at` |
|
||||
|
||||
Angles: heading and roll are normalized into [0, 360) — the long form can
|
||||
write `roll=360` for a camera at 359.6°, which the compact form stores as 0 —
|
||||
and written as the nearer signed turn, so 350° costs one byte (−10).
|
||||
When a state is encoded, out-of-range input is clamped (latitude, pitch) or
|
||||
wrapped (longitude). A payload that stores a latitude or longitude beyond
|
||||
±90/±180 cannot come from the encoder and is rejected.
|
||||
|
||||
### Layers (fields 10 and 11)
|
||||
|
||||
A layer's number is derived from its permanent layer-state token
|
||||
(`src/data/layerStateTokenReservations.json`): a one-character token is its
|
||||
index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The
|
||||
token ledger never reassigns a token, so layer numbers never change and new
|
||||
layers need no change here.
|
||||
|
||||
Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set
|
||||
when layer _n_ is on. Present-but-empty means "every layer off"; an absent
|
||||
field leaves the recipient's layers alone (the long form's `l=` versus no `l`).
|
||||
A thing view's entity layer is always on, so its bit is not written: in a
|
||||
thing view, present-but-empty means "only the entity's layer", and an absent
|
||||
field opens with only the entity's layer on (the app writes the field
|
||||
whenever it has a layer set).
|
||||
|
||||
Field 11 repeats `(layer number, option key, value)` for each option that
|
||||
differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The
|
||||
option key is `option token index × 2 + kind`: kind 0 means the value is a
|
||||
zigzag varint (an option whose long-form spelling is a canonical integer),
|
||||
kind 1 means a varint length and ASCII text (the long-form spelling). Values
|
||||
are decoded by the layer-state codec's own option decoders.
|
||||
|
||||
A followed aircraft, military aircraft or satellite is a thing view in the
|
||||
compact form, not a tracking option: the encoder moves it out of the layer
|
||||
options, and the converter moves it back for the long form.
|
||||
|
||||
### Panels (field 12)
|
||||
|
||||
Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where
|
||||
collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that
|
||||
differ from the frozen default layout are written, plus a collapsed-0 entry
|
||||
for each panel the state has no opinion about (an older long-form link that
|
||||
predates the panel); a panel with no entry takes its default. Values of 3 are
|
||||
reserved: a decoder ignores that entry, as for collapsed 0.
|
||||
|
||||
| # | Panel | Long-form token | Default |
|
||||
| --- | ---------------------- | --------------- | ------------------- |
|
||||
| 0 | `control-panel` | `c` | collapsed, unpinned |
|
||||
| 1 | `location-bar` | `l` | collapsed, unpinned |
|
||||
| 2 | `data-panel` | `d` | collapsed |
|
||||
| 3 | `cctv-panel` | `v` | collapsed |
|
||||
| 4 | `recent-imagery-panel` | `i` | expanded |
|
||||
| 5 | `street-level-panel` | `t` | collapsed |
|
||||
| 6 | `radio-panel` | `r` | collapsed |
|
||||
| 7 | `scene-panel` | `s` | collapsed |
|
||||
| 8 | `global-context-panel` | `g` | collapsed |
|
||||
| 9 | `pp-toggles` | `p` | collapsed |
|
||||
| 10 | `param-slider-panel` | `m` | expanded |
|
||||
|
||||
### Style parameters (field 27)
|
||||
|
||||
Pairs of varints `(token index, value × 100)` for the parameters of the
|
||||
view's style that differ from its defaults, using the long form's `sp` tokens
|
||||
(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter
|
||||
of the style: the stored ones over the style's defaults. A long link without
|
||||
`sp` converts to the style's defaults.
|
||||
|
||||
## View kinds
|
||||
|
||||
A **place** view (field 13 absent) opens at its camera, fields 2–7.
|
||||
|
||||
A **thing** view (field 13 present) follows an entity. Field 13 is a
|
||||
sub-message:
|
||||
|
||||
| # | Field | Encoding | Default |
|
||||
| --- | ----------- | -------------------------------------------------------- | ------- |
|
||||
| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | |
|
||||
| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | |
|
||||
| 3 | vessel | entity id: varint (MMSI) or LEN text | |
|
||||
| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | |
|
||||
| 5 | range | uint metres from the entity to the camera | 2000 |
|
||||
| 6 | heading | angle, the camera's heading while following | 0 |
|
||||
| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 |
|
||||
| 8 | fix lat | coord, the entity's last-known latitude | none |
|
||||
| 9 | fix lon | coord, its last-known longitude | none |
|
||||
| 10 | fix alt | sint metres | 0 |
|
||||
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
|
||||
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
|
||||
|
||||
The entity kind is the field that carries the id (its code in
|
||||
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
|
||||
present (two is malformed). Entity ids are the ones the layers look up: an
|
||||
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
|
||||
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
|
||||
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
|
||||
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
|
||||
|
||||
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
|
||||
the entity from `range` metres away, heading clockwise from north, in the
|
||||
entity's local east-north-up frame. The defaults match the app's own follow
|
||||
camera, which looks north at about −35°, so only its range is written.
|
||||
|
||||
Fields 8–12 are the last-known fix, the entity's position when the link was
|
||||
made. Fields 8 and 9 are both written whenever there is a fix; their presence
|
||||
marks it. Its time is the created-at time (field 8 at the top level) minus
|
||||
field 11, or field 12 when the view has no created-at time.
|
||||
|
||||
**The camera of a thing view with a fix is derived, not stored**: fields 2–7
|
||||
are not written, and the camera is where following the fix from the offset
|
||||
puts it (`followCameraFor` in `src/share/follow.js`): the camera sits
|
||||
`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up
|
||||
of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m,
|
||||
and looks back at the fix (its heading and pitch are those of that direction
|
||||
at the camera, roll 0; at follow distances they equal h and p). A
|
||||
decoder ignores fields 2–7 when the fix is present. A thing view without a
|
||||
fix (one converted from a long link, which records neither offset nor fix)
|
||||
writes its camera in fields 2–7 like a place view.
|
||||
|
||||
A decoder that meets a view kind or an entity kind it does not know (an id in
|
||||
a thing field it skips) opens the view as a place at its camera — the derived
|
||||
one when there is a fix — and reports `fallback`.
|
||||
|
||||
## Decoding rules
|
||||
|
||||
`decodeShareView(code)` takes the bare code and never throws. It returns
|
||||
`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`.
|
||||
|
||||
Rejected (the whole link): input over 2048 characters, bad base64url
|
||||
(padding, foreign characters, impossible length, stray bits), fewer than two
|
||||
bytes, a check byte that does not match (`checksum`), an unknown version byte,
|
||||
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
|
||||
overlong varint, a LEN longer than what remains (at any level, including
|
||||
inside the thing sub-message), a reserved wire type, field number 0, a known
|
||||
field with the wrong wire type or repeated, two entity ids in one thing, a
|
||||
duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||
longitude (camera or fix) beyond ±90/±180.
|
||||
|
||||
Tolerated and reported in `unknownFields`: unknown fields at any level
|
||||
(skipped by wire type), unknown enum codes and names (the default is used),
|
||||
unknown flag bits, layers, panel values and style parameters, and layer
|
||||
options the layer-state codec rejects or that are filed under a layer other
|
||||
than their owner. Detection mode and allocation names outside their lists
|
||||
fall back to the default rather than reaching the long form's lenient
|
||||
migration. The map stack and HUD variant are open: a name outside their lists
|
||||
that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what
|
||||
it means) and is not reported; only a name outside that grammar is. A
|
||||
created-at time in the future is dropped, as the long form's `at` is.
|
||||
|
||||
## Changing the format
|
||||
|
||||
These rules keep every link that was ever shared working, in every version of
|
||||
the app that can open it.
|
||||
|
||||
1. **Field numbers are permanent.** Never renumber, never reuse. A field that
|
||||
is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`.
|
||||
2. **A field's type and wire type are permanent.** To change one, add a new
|
||||
field.
|
||||
3. **Defaults are frozen meanings.** A default is what an omitted field meant
|
||||
when links were written. If the app's default moves, leave the table alone:
|
||||
the encoder will start writing the new value explicitly. Style defaults
|
||||
are read from the app's style tables; when those move, freeze the old
|
||||
values in the codec (see Style defaults). The same applies
|
||||
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
|
||||
4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel
|
||||
numbers and style parameter tokens only grow.
|
||||
5. **Add, don't change.** A new setting takes the next free field number
|
||||
(28 onwards; fields 1–15 have one-byte tags and are all taken). A new
|
||||
boolean takes the next flag bit instead, which costs nothing when it is at
|
||||
its default.
|
||||
6. **New view kinds and entity kinds** get the next code. A new entity kind's
|
||||
id takes the next free thing field (13 onwards), so older apps skip it and
|
||||
fall back to the camera. A thing that needs more than an id (for example a
|
||||
CCTV camera, a radio station or a transit vehicle) adds further thing
|
||||
fields the same way.
|
||||
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
|
||||
(a test fails until it has one).
|
||||
8. **Bump the version byte only** for a change older decoders must refuse.
|
||||
Decoders reject any version they do not know. The trailing CRC-8 check
|
||||
byte belongs to every version.
|
||||
9. **Derivations are frozen too.** `followCameraFor` and the follow-offset
|
||||
defaults are part of the format: changing them moves the camera of every
|
||||
thing link already shared.
|
||||
|
||||
Add a test for every change: a round trip, and a payload written with the new
|
||||
field that the previous decoder still reads.
|
||||
+2
-2
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
|
||||
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
|
||||
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
|
||||
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
|
||||
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Translating God's Eye View
|
||||
|
||||
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`).
|
||||
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
|
||||
|
||||
## Language notes
|
||||
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one.
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
+15
-9
@@ -3,10 +3,11 @@
|
||||
God's Eye View uses explicit auth modes for `/api/flights`:
|
||||
|
||||
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
|
||||
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
|
||||
- `OPENSKY_AUTH_MODE=basic`
|
||||
- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous)
|
||||
- `OPENSKY_AUTH_MODE=anon`
|
||||
|
||||
`OPENSKY_AUTH_MODE=basic` is retired (see below).
|
||||
|
||||
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
|
||||
|
||||
## Quick Start (OAuth Client JSON)
|
||||
@@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order:
|
||||
2. `OPENSKY_CREDENTIALS_FILE`
|
||||
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
|
||||
|
||||
## Basic Auth (Legacy)
|
||||
The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and
|
||||
Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any
|
||||
platform. It reads the file on each token refresh.
|
||||
|
||||
Basic auth is still supported for now, but OpenSky is moving away from it.
|
||||
## Basic Auth (Retired)
|
||||
|
||||
```bash
|
||||
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
|
||||
```
|
||||
OpenSky stopped accepting username/password (HTTP Basic) sign-in on
|
||||
2026-03-18; its API now takes only OAuth2 client credentials. With
|
||||
`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` /
|
||||
`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and
|
||||
logs once what to set instead. Create an API client on your OpenSky
|
||||
account page and use its `credentials.json` as above.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -61,6 +67,6 @@ Useful reasons:
|
||||
|
||||
- `oauth_invalid_or_missing`: OAuth client not found/usable
|
||||
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
|
||||
- `basic_invalid_credentials`: username/password rejected
|
||||
- `missing_basic_creds`: basic mode missing user/pass
|
||||
- `missing_oauth_creds`: auto mode found no OAuth client
|
||||
- `basic_retired`: basic mode requested; answered anonymously
|
||||
- `rate_limited`: OpenSky throttling
|
||||
@@ -0,0 +1,77 @@
|
||||
# Simplified Chinese (`zh-CN`)
|
||||
|
||||
Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||
|
||||
Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
|
||||
|
||||
## Style
|
||||
|
||||
- Mainland usage and Simplified characters. Address the user neutrally, without 您/你 where a plain imperative works: 「点击地图」, 「重试」.
|
||||
- Chinese has no case: an UPPERCASE English HUD label becomes the same short Chinese label. Keep labels as short as the English: 「实时」, 「过期」, 「关」.
|
||||
- Full-width punctuation in Chinese text: ,。:;?!()“”. A dash is 「——」 without spaces; keep `·` as the HUD separator and `…` for ellipsis.
|
||||
- One space between Chinese and Latin letters or digits: 「AIS 船舶」, 「24 小时」, 「{count} 个」. No space next to full-width punctuation.
|
||||
- Units: km, m, km/h, m/s, kn, ft, MW, °, % stay as written; time words are 小时, 分钟, 秒 after a number (「5 分钟前」).
|
||||
- Product names, provider names and codes stay as they are: Google, Cesium, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, CONUS.
|
||||
|
||||
## Plurals
|
||||
|
||||
`Intl.PluralRules('zh-CN')` has one category, `other`: a `tn()` entry has only that form. Use a measure word next to the number where Chinese needs one: 「{count} 个密钥」, 「{count} 架飞机」, 「{count} 艘船」.
|
||||
|
||||
## Glossary
|
||||
|
||||
| English | 中文 |
|
||||
| ------------------------------------- | --------------------------- |
|
||||
| layer / data layer | 图层 / 数据图层 |
|
||||
| feed | 数据源 |
|
||||
| source / data source | 来源 / 数据源 |
|
||||
| LIVE (status) / live data | 实时 / 实时数据 |
|
||||
| SIMULATED | 模拟 |
|
||||
| stale / degraded / unavailable | 过期 / 降级 / 不可用 |
|
||||
| retry | 重试 |
|
||||
| key (API key) / POWER UP | 密钥(API 密钥)/ 增强 |
|
||||
| Provider Settings | 服务商设置 |
|
||||
| globe / map / map source / basemap | 地球 / 地图 / 地图源 / 底图 |
|
||||
| aircraft / flight / military flights | 飞机 / 航班 / 军用航班 |
|
||||
| vessel, ship | 船舶 |
|
||||
| satellite | 卫星 |
|
||||
| launch (rocket) / space mission | 发射 / 太空任务 |
|
||||
| contact (tracked object) | 目标 |
|
||||
| installation, site | 设施 |
|
||||
| camera / CCTV camera | 摄像头 / CCTV 摄像头 |
|
||||
| earthquake / active fire | 地震 / 活跃火点 |
|
||||
| scene / shot | 场景 / 镜头 |
|
||||
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
|
||||
| preset / visual preset | 预设 / 视觉预设 |
|
||||
| Display / HUD layout | 显示 / HUD 布局 |
|
||||
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 |
|
||||
| Scope (circular viewport mask) / keyhole | 视域 |
|
||||
| Clean UI | 纯净界面 |
|
||||
| cockpit | 座舱 |
|
||||
| heading / altitude / ground speed | 航向 / 高度 / 地速 |
|
||||
| map heading / North up | 方位 / 正北朝上 |
|
||||
| Street Level / Street View (Google) | 街景 / 街景(Google) |
|
||||
| Recent Imagery | 近期影像 |
|
||||
| Global Context / Context (panel) | 全局态势 / 态势 |
|
||||
| radio (station) | 电台 |
|
||||
| transit / bikeshare | 公共交通 / 共享单车 |
|
||||
| Previous / Next | 上一个 / 下一个 |
|
||||
| compass points (N, NNE, …) | 北, 北东北, … |
|
||||
| weather field / FIELD (selector) | 气象场 / 要素 |
|
||||
| weather model / forecast lead hours | 模式 / 预报时效 |
|
||||
| valid (forecast time) | 有效时间 |
|
||||
| scene cloud / mosaic | 整景云量 / 镶嵌图 |
|
||||
| pin (Recent Imagery) | 固定 |
|
||||
| THIS (voice pointer) | 所指 |
|
||||
| outline / trace an outline | 轮廓 / 绘制轮廓 |
|
||||
| mark (map annotation) | 标注 |
|
||||
| camera badge | 摄像头标记 |
|
||||
| provider / embedded provider controls | 服务商 / 平台控件 |
|
||||
| share (a view or link) | 分享 |
|
||||
| AI agent | AI 智能体 |
|
||||
| stage (rocket) / downrange | 子级 / 航程 |
|
||||
| capture shot / camera move | 保存镜头 / 运镜 |
|
||||
| story beat / evidence beat | 事件节点 / 证据节点 |
|
||||
| camera pose | 位姿 |
|
||||
| forecast uncertainty cone | 预报不确定范围 |
|
||||
| seed (catalog data) | 预置 |
|
||||
| frame (bring into view) | 将…纳入视野 |
|
||||
@@ -0,0 +1,17 @@
|
||||
# Traditional Chinese (`zh-Hant`)
|
||||
|
||||
Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||
|
||||
`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs.
|
||||
|
||||
After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布.
|
||||
|
||||
## Style
|
||||
|
||||
- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters.
|
||||
- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前.
|
||||
- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese.
|
||||
|
||||
## Plurals
|
||||
|
||||
`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」.
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,upload_file,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- gev:template scene-chrome -->
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gods-eye-view",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gods-eye-view",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
|
||||
+9
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -49,7 +49,6 @@
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"test:track": "node scripts/track-regression.mjs",
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
||||
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
|
||||
"qa:street-level": "node scripts/qa-street-level.mjs",
|
||||
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
||||
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||
@@ -169,6 +168,7 @@
|
||||
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
|
||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||
"./layers/gnss": "./src/layers/gnss/index.js",
|
||||
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
|
||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||
"./layers/firms": "./src/layers/firms/index.js",
|
||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||
@@ -223,6 +223,8 @@
|
||||
"./data/lifecycle": "./src/data/lifecycle.js",
|
||||
"./ui/navigation": "./src/ui/navigationController.js",
|
||||
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
||||
"./share/view-codec": "./src/share/viewCodec.js",
|
||||
"./share/link-path": "./src/share/shareLinkPath.js",
|
||||
"./ui/visual-settings": "./src/ui/visualSettings.js",
|
||||
"./ui/panel-chrome": "./src/ui/panelChrome.js",
|
||||
"./layers/flights/records": "./src/layers/flights/records.js",
|
||||
@@ -245,6 +247,7 @@
|
||||
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
|
||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
||||
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
|
||||
"./layers/firms/source": "./src/layers/firms/source.js",
|
||||
"./layers/installations/source": "./src/layers/installations/source.js",
|
||||
"./layers/launches/source": "./src/layers/launches/source.js",
|
||||
@@ -273,10 +276,13 @@
|
||||
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
|
||||
"./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js",
|
||||
"./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js",
|
||||
"./tools": "./src/tools/index.js",
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.js"
|
||||
"./view": "./src/view/index.js",
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -22,8 +22,10 @@ module.exports = {
|
||||
if (starting) {
|
||||
const local = info.local('start.js');
|
||||
if (local?.url) {
|
||||
// Open in the system browser without changing the app's framing policy.
|
||||
// This also avoids relying on Pinokio's embedded browser behavior.
|
||||
return [
|
||||
{ default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url },
|
||||
{ default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url, popout: true },
|
||||
{ icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ under the license listed below.
|
||||
|---|---|---|---|---|
|
||||
| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. |
|
||||
| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. |
|
||||
| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. |
|
||||
| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/data/local_data/sky/bright_stars.json from the Yale Bright Star
|
||||
* Catalogue, 5th Revised Edition (Hoffleit & Warren 1991).
|
||||
*
|
||||
* Source: CDS VizieR catalogue V/50, https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
|
||||
* Fetched: https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz
|
||||
* Format: fixed-width ASCII, 197 bytes per record, byte layout in
|
||||
* https://cdsarc.cds.unistra.fr/ftp/V/50/ReadMe
|
||||
* License: public domain (Yale University Observatory / NASA ADC); CDS asks
|
||||
* for an acknowledgement, which the Data attribution popover carries.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. J2000 RA/Dec (bytes 76-90), V magnitude (103-107) and B−V (110-114).
|
||||
* 2. The 14 records without a J2000 position (novae and other objects later
|
||||
* found not to be stars) are dropped.
|
||||
* 3. RA/Dec rounded to 3 decimals of a degree (3.6″ — a fraction of a pixel
|
||||
* at any field of view the globe uses), magnitudes to 2.
|
||||
* 4. Missing B−V is stored as null; the renderer gives it a neutral colour.
|
||||
* 5. Records keep catalogue (HR) order for a stable diff.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-bright-stars.mjs [catalog | catalog.gz]
|
||||
* With no argument it downloads the catalogue from CDS.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz';
|
||||
const OUT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
'bright_stars.json',
|
||||
);
|
||||
|
||||
/** Slice 1-based inclusive byte columns the way the CDS ReadMe numbers them. */
|
||||
function columns(line, from, to) {
|
||||
return line.slice(from - 1, to).trim();
|
||||
}
|
||||
|
||||
/** Parse a fixed-width float; blank fields are null. */
|
||||
function parseField(text) {
|
||||
if (text === '') return null;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one BSC5 record. Returns null for records without a J2000 position
|
||||
* or V magnitude.
|
||||
*/
|
||||
export function parseBscRecord(line) {
|
||||
const hr = parseField(columns(line, 1, 4));
|
||||
const raH = parseField(columns(line, 76, 77));
|
||||
const raM = parseField(columns(line, 78, 79));
|
||||
const raS = parseField(columns(line, 80, 83));
|
||||
const sign = columns(line, 84, 84);
|
||||
const deD = parseField(columns(line, 85, 86));
|
||||
const deM = parseField(columns(line, 87, 88));
|
||||
const deS = parseField(columns(line, 89, 90));
|
||||
const vmag = parseField(columns(line, 103, 107));
|
||||
const bv = parseField(columns(line, 110, 114));
|
||||
if (
|
||||
hr === null ||
|
||||
raH === null ||
|
||||
raM === null ||
|
||||
raS === null ||
|
||||
deD === null ||
|
||||
deM === null ||
|
||||
deS === null ||
|
||||
vmag === null ||
|
||||
(sign !== '+' && sign !== '-')
|
||||
)
|
||||
return null;
|
||||
const raDeg = (raH + raM / 60 + raS / 3600) * 15;
|
||||
const decDeg = (sign === '-' ? -1 : 1) * (deD + deM / 60 + deS / 3600);
|
||||
return { hr, raDeg, decDeg, vmag, bv };
|
||||
}
|
||||
|
||||
const round = (value, decimals) => {
|
||||
const scale = 10 ** decimals;
|
||||
return Math.round(value * scale) / scale;
|
||||
};
|
||||
|
||||
/** Build the committed pack from the raw catalogue text. */
|
||||
export function buildStarPack(text, { fetched = null } = {}) {
|
||||
const stars = [];
|
||||
let dropped = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
const record = parseBscRecord(line);
|
||||
if (!record) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
stars.push(
|
||||
round(record.raDeg, 3),
|
||||
round(record.decDeg, 3),
|
||||
round(record.vmag, 2),
|
||||
record.bv === null ? null : round(record.bv, 2),
|
||||
);
|
||||
}
|
||||
return {
|
||||
meta: {
|
||||
source:
|
||||
'Yale Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren 1991), CDS V/50',
|
||||
url: SOURCE_URL,
|
||||
license: 'public domain',
|
||||
fetched,
|
||||
frame: 'ICRF/J2000, epoch 2000.0',
|
||||
columns: ['raDeg', 'decDeg', 'vmag', 'bv'],
|
||||
count: stars.length / 4,
|
||||
dropped,
|
||||
},
|
||||
stars,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCatalogue(file) {
|
||||
if (!file) {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) throw new Error(`${SOURCE_URL}: HTTP ${response.status}`);
|
||||
return zlib
|
||||
.gunzipSync(Buffer.from(await response.arrayBuffer()))
|
||||
.toString('latin1');
|
||||
}
|
||||
const bytes = fs.readFileSync(file);
|
||||
return (file.endsWith('.gz') ? zlib.gunzipSync(bytes) : bytes).toString(
|
||||
'latin1',
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
const text = await readCatalogue(file);
|
||||
const pack = buildStarPack(text, {
|
||||
fetched: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
// One star per line keeps the JSON compact and the diff readable.
|
||||
const rows = [];
|
||||
for (let i = 0; i < pack.stars.length; i += 4)
|
||||
rows.push(JSON.stringify(pack.stars.slice(i, i + 4)).slice(1, -1));
|
||||
const body = `{"meta":${JSON.stringify(pack.meta)},\n"stars":[\n${rows.join(',\n')}\n]}\n`;
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
fs.writeFileSync(OUT, body);
|
||||
console.log(
|
||||
`wrote ${pack.meta.count} stars (${pack.meta.dropped} dropped) → ${path.relative(process.cwd(), OUT)} (${body.length} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the soft Milky Way cube map in src/data/local_data/sky/ from the ESO
|
||||
* all-sky Milky Way panorama.
|
||||
*
|
||||
* Source page: https://www.eso.org/public/images/eso0932a/
|
||||
* Fetched: https://cdn.eso.org/images/original/eso0932a.tif
|
||||
* (6000×3000 8-bit sRGB TIFF, 27.7 MB; sha256 in README.md)
|
||||
* Credit: ESO/S. Brunier
|
||||
* License: CC BY 4.0 (https://www.eso.org/public/outreach/copyright/).
|
||||
* Modified: stars removed, blurred, reprojected to ICRF cube
|
||||
* faces and tone-mapped, as below.
|
||||
* Frame: plate carrée in galactic coordinates, centred on the Galactic
|
||||
* Centre, galactic longitude increasing to the left.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. Resample to WORK_WIDTH×WORK_WIDTH/2, sRGB to linear light.
|
||||
* 2. Remove stars with a morphological opening: the renderer draws
|
||||
* catalogue stars itself, sharp and in the right place.
|
||||
* 3. Paint out the planets the 2008–2010 exposures caught (PLANETS).
|
||||
* 4. Blur, round on the sphere: σ = BLUR_DEG, widening to FAINT_BLUR_DEG
|
||||
* in the faint glow so unresolved stars leave no clumps.
|
||||
* 5. Resample into six WebGL cube faces (uploaded without flipY) with the
|
||||
* ICRF axes: +X to the March equinox, +Z to the north celestial pole.
|
||||
* 6. Median sky to black with a soft knee, brightest 0.01 % to ~97 %
|
||||
* white, on luminance to keep the hue; gamma 1/2.2, dither, WebP.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-milky-way.mjs <eso0932a.tif>
|
||||
* Needs the sharp dev dependency.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const FACE_SIZE = 1024;
|
||||
export const BLUR_DEG = 0.4;
|
||||
// Faint glow is grain of unresolved stars and needs the wide blur; bright
|
||||
// parts keep the fine one and their dust lanes. FAINT_RANGE (fraction of
|
||||
// black-to-white) is where the two blend.
|
||||
export const FAINT_BLUR_DEG = 2;
|
||||
const FAINT_RANGE = [0.005, 0.08];
|
||||
const WORK_WIDTH = 4096;
|
||||
const OPEN_RADIUS = 3;
|
||||
const WEBP_QUALITY = 96;
|
||||
// The median sky is black: above it the glow away from the plane reads as
|
||||
// grey blotches. SOFT_KNEE eases the cut in so it draws no edges.
|
||||
const BLACK_PERCENTILE = 0.5;
|
||||
const SOFT_KNEE = 0.03;
|
||||
const WHITE_PERCENTILE = 0.9999;
|
||||
const OUT_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
);
|
||||
|
||||
/**
|
||||
* Bright planets in the panorama, galactic longitude/latitude in degrees and
|
||||
* the radius painted over with the surrounding sky. By position both are
|
||||
* Jupiter, in Sagittarius (2008) and Aquarius (2010); no catalogue star lies
|
||||
* under either.
|
||||
*/
|
||||
export const PLANETS = Object.freeze([
|
||||
{ l: 13.4, b: -16.6, radiusDeg: 2.5 },
|
||||
{ l: 69.0, b: -58.4, radiusDeg: 2.5 },
|
||||
]);
|
||||
|
||||
/** Cube faces in WebGL upload order. */
|
||||
export const CUBE_FACES = Object.freeze([
|
||||
'positiveX',
|
||||
'negativeX',
|
||||
'positiveY',
|
||||
'negativeY',
|
||||
'positiveZ',
|
||||
'negativeZ',
|
||||
]);
|
||||
export const FACE_FILE_SUFFIX = Object.freeze({
|
||||
positiveX: 'px',
|
||||
negativeX: 'nx',
|
||||
positiveY: 'py',
|
||||
negativeY: 'ny',
|
||||
positiveZ: 'pz',
|
||||
negativeZ: 'nz',
|
||||
});
|
||||
|
||||
/**
|
||||
* ICRF (J2000 equatorial) → galactic rotation, rows = galactic axes
|
||||
* (Hipparcos catalogue, ESA SP-1200 vol. 1 §1.5.3).
|
||||
*/
|
||||
export const ICRF_TO_GALACTIC = Object.freeze([
|
||||
[-0.0548755604162154, -0.873437090234885, -0.4838350155487132],
|
||||
[0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
|
||||
[-0.867666149019004, -0.1980763734312015, 0.4559837761750669],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Direction (not normalized) that the WebGL cube-map lookup sends to texel
|
||||
* coordinate (s, t) ∈ [0, 1]² of `face`, t = 0 being the first image row.
|
||||
* Inverse of the OpenGL ES 3.0 §3.8.10 major-axis table.
|
||||
*/
|
||||
export function cubeFaceDirection(face, s, t) {
|
||||
const sc = 2 * s - 1;
|
||||
const tc = 2 * t - 1;
|
||||
switch (face) {
|
||||
case 'positiveX':
|
||||
return [1, -tc, -sc];
|
||||
case 'negativeX':
|
||||
return [-1, -tc, sc];
|
||||
case 'positiveY':
|
||||
return [sc, 1, tc];
|
||||
case 'negativeY':
|
||||
return [sc, -1, -tc];
|
||||
case 'positiveZ':
|
||||
return [sc, -tc, 1];
|
||||
case 'negativeZ':
|
||||
return [-sc, -tc, -1];
|
||||
default:
|
||||
throw new RangeError(`Unknown cube face ${face}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rotate an ICRF direction into galactic coordinates. */
|
||||
export function icrfToGalactic([x, y, z]) {
|
||||
return ICRF_TO_GALACTIC.map((row) => row[0] * x + row[1] * y + row[2] * z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a direction to (u, v) ∈ [0, 1)² of a plate carrée centred on
|
||||
* longitude 0 with longitude increasing to the left and v = 0 at +90°.
|
||||
*/
|
||||
export function directionToPlateCarree(x, y, z) {
|
||||
const longitude = Math.atan2(y, x);
|
||||
const latitude = Math.atan2(z, Math.hypot(x, y));
|
||||
let u = 0.5 - longitude / (2 * Math.PI);
|
||||
u -= Math.floor(u);
|
||||
return [u, 0.5 - latitude / Math.PI];
|
||||
}
|
||||
|
||||
/** Box widths whose three passes approximate a Gaussian of `sigma` (Kovesi). */
|
||||
function boxRadii(sigma) {
|
||||
const ideal = Math.sqrt((12 * sigma * sigma) / 3 + 1);
|
||||
let lower = Math.floor(ideal);
|
||||
if (lower % 2 === 0) lower -= 1;
|
||||
const upper = lower + 2;
|
||||
const m = Math.round(
|
||||
(12 * sigma * sigma - 3 * lower * lower - 12 * lower - 9) /
|
||||
(-4 * lower - 4),
|
||||
);
|
||||
return [0, 1, 2].map((i) => ((i < m ? lower : upper) - 1) / 2);
|
||||
}
|
||||
|
||||
/** Index along a line, wrapped or clamped at the ends. */
|
||||
function lineIndex(i, length, wrap) {
|
||||
if (wrap) return ((i % length) + length) % length;
|
||||
return i < 0 ? 0 : i >= length ? length - 1 : i;
|
||||
}
|
||||
|
||||
/** Running-sum box blur of one strided line. */
|
||||
function boxLine(src, dst, offset, stride, length, radius, wrap) {
|
||||
if (radius < 1) {
|
||||
for (let i = 0; i < length; i++)
|
||||
dst[offset + i * stride] = src[offset + i * stride];
|
||||
return;
|
||||
}
|
||||
const r = wrap ? Math.min(radius, Math.floor((length - 1) / 2)) : radius;
|
||||
const at = (i) => src[offset + lineIndex(i, length, wrap) * stride];
|
||||
let sum = 0;
|
||||
for (let i = -r; i <= r; i++) sum += at(i);
|
||||
const scale = 1 / (2 * r + 1);
|
||||
for (let i = 0; i < length; i++) {
|
||||
dst[offset + i * stride] = sum * scale;
|
||||
sum += at(i + r + 1) - at(i - r);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum or maximum over a (2r+1) window of one strided line. */
|
||||
function extremumLine(src, dst, offset, stride, length, radius, wrap, pick) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
let value = src[offset + lineIndex(i - radius, length, wrap) * stride];
|
||||
for (let k = -radius + 1; k <= radius; k++)
|
||||
value = pick(
|
||||
value,
|
||||
src[offset + lineIndex(i + k, length, wrap) * stride],
|
||||
);
|
||||
dst[offset + i * stride] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Separable minimum or maximum filter over a plate-carrée plane, in place. */
|
||||
function extremumPlane(plane, width, height, radius, pick) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++)
|
||||
extremumLine(plane, scratch, y * width, 1, width, radius, true, pick);
|
||||
for (let x = 0; x < width; x++)
|
||||
extremumLine(scratch, plane, x, width, height, radius, false, pick);
|
||||
}
|
||||
|
||||
/** Blur one plate-carrée channel in place, round on the sphere. */
|
||||
function blurPlane(plane, width, height, sigmaPx) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const latitude = (0.5 - (y + 0.5) / height) * Math.PI;
|
||||
const sigmaX = Math.min(
|
||||
width / 4,
|
||||
sigmaPx / Math.max(Math.cos(latitude), 1e-3),
|
||||
);
|
||||
for (const radius of boxRadii(sigmaX)) {
|
||||
boxLine(plane, scratch, y * width, 1, width, Math.round(radius), true);
|
||||
plane.set(scratch.subarray(y * width, (y + 1) * width), y * width);
|
||||
}
|
||||
}
|
||||
for (const radius of boxRadii(sigmaPx)) {
|
||||
for (let x = 0; x < width; x++)
|
||||
boxLine(plane, scratch, x, width, height, Math.round(radius), false);
|
||||
plane.set(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unit vector for galactic longitude and latitude in degrees. */
|
||||
function galacticVector(l, b) {
|
||||
const lr = (l * Math.PI) / 180;
|
||||
const br = (b * Math.PI) / 180;
|
||||
return [
|
||||
Math.cos(br) * Math.cos(lr),
|
||||
Math.cos(br) * Math.sin(lr),
|
||||
Math.sin(br),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a disc of the sky with the median of the ring just outside it, per
|
||||
* channel. A median, not a mean: the planet's diffraction spikes reach far
|
||||
* past the disc and would lift a mean well above the sky. The blur that
|
||||
* follows hides the seam.
|
||||
*/
|
||||
function paintOut(planes, width, height, { l, b, radiusDeg }) {
|
||||
const centre = galacticVector(l, b);
|
||||
const inner = Math.cos((radiusDeg * Math.PI) / 180);
|
||||
const outer = Math.cos(((radiusDeg + 1) * Math.PI) / 180);
|
||||
const disc = [];
|
||||
const ring = [];
|
||||
const span = Math.ceil(((radiusDeg + 1) / 180) * height);
|
||||
const row = Math.round((0.5 - b / 180) * height);
|
||||
for (let y = Math.max(0, row - span); y < Math.min(height, row + span); y++) {
|
||||
const latitude = 90 - ((y + 0.5) * 180) / height;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const longitude = (0.5 - (x + 0.5) / width) * 360;
|
||||
const p = galacticVector(longitude, latitude);
|
||||
const cos = p[0] * centre[0] + p[1] * centre[1] + p[2] * centre[2];
|
||||
if (cos >= inner) disc.push(y * width + x);
|
||||
else if (cos >= outer) ring.push(y * width + x);
|
||||
}
|
||||
}
|
||||
for (const plane of planes) {
|
||||
const values = Float32Array.from(ring, (index) => plane[index]).sort();
|
||||
const median = values[values.length >> 1];
|
||||
for (const index of disc) plane[index] = median;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bilinear sample with longitude wrap and latitude clamp. */
|
||||
function samplePlane(plane, width, height, u, v) {
|
||||
const fx = u * width - 0.5;
|
||||
const fy = Math.min(Math.max(v * height - 0.5, 0), height - 1);
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const ax = fx - x0;
|
||||
const ay = fy - y0;
|
||||
const xa = ((x0 % width) + width) % width;
|
||||
const xb = (xa + 1) % width;
|
||||
const yb = Math.min(y0 + 1, height - 1);
|
||||
const top = plane[y0 * width + xa] * (1 - ax) + plane[y0 * width + xb] * ax;
|
||||
const bottom =
|
||||
plane[yb * width + xa] * (1 - ax) + plane[yb * width + xb] * ax;
|
||||
return top * (1 - ay) + bottom * ay;
|
||||
}
|
||||
|
||||
/** Deterministic hash noise in [-0.5, 0.5) for dithering. */
|
||||
function dither(index, channel) {
|
||||
let h = (index * 374761393 + channel * 668265263) | 0;
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
h ^= h >>> 16;
|
||||
return (h >>> 0) / 4294967296 - 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Black level with a soft knee: 0 at and below the level, then a quadratic
|
||||
* ease that joins the straight line, so faint sky fades out instead of
|
||||
* ending in a contour.
|
||||
*/
|
||||
export function softBlack(x, knee = SOFT_KNEE) {
|
||||
return x <= 0 ? 0 : (x * x) / (x + knee);
|
||||
}
|
||||
|
||||
export function smoothstep(edge0, edge1, x) {
|
||||
const t = Math.min(Math.max((x - edge0) / (edge1 - edge0), 0), 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per pixel, keep `fine` where `weight(luminance of coarse)` is 1 and take
|
||||
* `coarse` where it is 0, blending in between. The weight follows the coarse
|
||||
* copy, so it is smooth itself and draws no seams along dark lanes. Writes
|
||||
* into `fine`.
|
||||
*/
|
||||
function blendByBrightness(fine, coarse, weight) {
|
||||
const [r] = fine;
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
const w = weight((coarse[0][i] + coarse[1][i] + coarse[2][i]) / 3);
|
||||
for (let c = 0; c < 3; c++)
|
||||
fine[c][i] = coarse[c][i] + (fine[c][i] - coarse[c][i]) * w;
|
||||
}
|
||||
}
|
||||
|
||||
/** Value at `fraction` of the sorted luminance. */
|
||||
function luminancePercentile(planes, fraction) {
|
||||
const [r, g, b] = planes;
|
||||
const lum = new Float32Array(r.length);
|
||||
for (let i = 0; i < lum.length; i++) lum[i] = (r[i] + g[i] + b[i]) / 3;
|
||||
lum.sort();
|
||||
return lum[Math.min(lum.length - 1, Math.floor(fraction * lum.length))];
|
||||
}
|
||||
|
||||
const srgbToLinear = (value) =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
async function decodePanorama(sharp, file) {
|
||||
const width = WORK_WIDTH;
|
||||
const height = WORK_WIDTH / 2;
|
||||
const raw = await sharp(file)
|
||||
.resize(width, height, { fit: 'fill', kernel: 'lanczos3' })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const lut = Float32Array.from({ length: 256 }, (_, i) =>
|
||||
srgbToLinear(i / 255),
|
||||
);
|
||||
const planes = [0, 1, 2].map(() => new Float32Array(width * height));
|
||||
for (let i = 0; i < width * height; i++)
|
||||
for (let c = 0; c < 3; c++) planes[c][i] = lut[raw[i * 3 + c]];
|
||||
return { width, height, planes };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
if (!file) throw new Error('usage: build-milky-way.mjs <eso0932a.tif>');
|
||||
const { default: sharp } = await import('sharp');
|
||||
const { width, height, planes } = await decodePanorama(sharp, file);
|
||||
const sigmaPx = (BLUR_DEG / 180) * height;
|
||||
for (const plane of planes) {
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.min);
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.max);
|
||||
}
|
||||
// After the opening: it sinks the starry sky to the dark gaps between
|
||||
// stars, and a fill taken before it would stay brighter than the sky.
|
||||
for (const planet of PLANETS) paintOut(planes, width, height, planet);
|
||||
const wide = planes.map((plane) => Float32Array.from(plane));
|
||||
for (const plane of planes) blurPlane(plane, width, height, sigmaPx);
|
||||
for (const plane of wide)
|
||||
blurPlane(plane, width, height, (FAINT_BLUR_DEG / 180) * height);
|
||||
// Levels from the fine blur, so the blend weight and the output share
|
||||
// them; the blend moves the median only in the faint sky it smooths.
|
||||
const black = luminancePercentile(planes, BLACK_PERCENTILE);
|
||||
const white = luminancePercentile(planes, WHITE_PERCENTILE);
|
||||
blendByBrightness(planes, wide, (luminance) =>
|
||||
smoothstep(
|
||||
FAINT_RANGE[0],
|
||||
FAINT_RANGE[1],
|
||||
(luminance - black) / (white - black),
|
||||
),
|
||||
);
|
||||
let total = 0;
|
||||
for (const face of CUBE_FACES) {
|
||||
const pixels = Buffer.alloc(FACE_SIZE * FACE_SIZE * 3);
|
||||
for (let j = 0; j < FACE_SIZE; j++) {
|
||||
for (let i = 0; i < FACE_SIZE; i++) {
|
||||
const direction = cubeFaceDirection(
|
||||
face,
|
||||
(i + 0.5) / FACE_SIZE,
|
||||
(j + 0.5) / FACE_SIZE,
|
||||
);
|
||||
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
|
||||
const index = j * FACE_SIZE + i;
|
||||
const rgb = [0, 1, 2].map((c) =>
|
||||
samplePlane(planes[c], width, height, u, v),
|
||||
);
|
||||
const luminance = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
||||
const level = softBlack((luminance - black) / (white - black));
|
||||
for (let c = 0; c < 3; c++) {
|
||||
// Scale every channel by the luminance curve to keep the hue.
|
||||
const linear = luminance > 0 ? (rgb[c] / luminance) * level : 0;
|
||||
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
|
||||
pixels[index * 3 + c] = Math.min(
|
||||
255,
|
||||
Math.max(0, Math.round(encoded + dither(index, c))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = path.join(OUT_DIR, `milkyway_${FACE_FILE_SUFFIX[face]}.webp`);
|
||||
const info = await sharp(pixels, {
|
||||
raw: { width: FACE_SIZE, height: FACE_SIZE, channels: 3 },
|
||||
})
|
||||
.webp({ quality: WEBP_QUALITY, smartSubsample: true, effort: 6 })
|
||||
.toFile(out);
|
||||
total += info.size;
|
||||
console.log(`${path.relative(process.cwd(), out)}: ${info.size} bytes`);
|
||||
}
|
||||
console.log(`total ${total} bytes`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -24,11 +24,10 @@ const renderer = (file) =>
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
file,
|
||||
);
|
||||
// Two compatibility composition entries intentionally select standalone defaults.
|
||||
// One compatibility composition entry intentionally selects standalone defaults.
|
||||
const compatibilityEdge = (from, to) =>
|
||||
(from === 'server/providers/local.js' &&
|
||||
to === 'server/standalone/key-setup.js') ||
|
||||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
|
||||
from === 'server/providers/local.js' &&
|
||||
to === 'server/standalone/key-setup.js';
|
||||
const portableExport = (key) =>
|
||||
key.startsWith('./sources/') ||
|
||||
/\/source$/.test(key) ||
|
||||
@@ -154,14 +153,6 @@ export function checkImportDirections(root) {
|
||||
file,
|
||||
'Actions must consume feed state without the manager facade',
|
||||
);
|
||||
if (
|
||||
file === 'src/app/constructCatalog.js' &&
|
||||
to === 'src/data/localGeojson.js'
|
||||
)
|
||||
report(
|
||||
file,
|
||||
'Catalog must use the services owner without compatibility layer construction',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) {
|
||||
|
||||
/**
|
||||
* Decide this launch's trusted-peer list. An operator value always wins, so a
|
||||
* compose override stays authoritative; otherwise the gateway alone is
|
||||
* compose override stays authoritative; `none` trusts no additional peers.
|
||||
* An unset or blank value means the gateway alone is
|
||||
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
|
||||
* exactly as under `npm run dev`.
|
||||
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
|
||||
@@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) {
|
||||
*/
|
||||
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
|
||||
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
|
||||
if (explicit.toLowerCase() === 'none') return '';
|
||||
if (explicit) return explicit;
|
||||
return defaultGatewayFromRouteTable(routeTable) ?? '';
|
||||
}
|
||||
@@ -109,15 +111,17 @@ async function start() {
|
||||
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
|
||||
);
|
||||
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
|
||||
if (trustedPeers) {
|
||||
// Set before Vite is imported: the dev server snapshots process.env during
|
||||
// configuration, and the admission gate reads it per request.
|
||||
// Set even an empty list before Vite loads .env: the resolved launcher policy
|
||||
// must remain authoritative when there is no trusted gateway.
|
||||
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||
if (trustedPeers) {
|
||||
console.log(
|
||||
fromOperator
|
||||
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
|
||||
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
|
||||
);
|
||||
} else if (fromOperator) {
|
||||
console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).');
|
||||
} else {
|
||||
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
|
||||
}
|
||||
|
||||
@@ -49,11 +49,6 @@ export function resetKartaViewFixture() {
|
||||
nearestLookups = 0;
|
||||
}
|
||||
|
||||
/** Nearest lookups answered since the last reset (the first got a 429). */
|
||||
export function kartaViewLookups() {
|
||||
return nearestLookups;
|
||||
}
|
||||
|
||||
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
||||
export function kartaViewPhotos() {
|
||||
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Mapillary Graph API and image CDN answered from fixtures, covering every
|
||||
* request the provider and MapillaryJS make while opening, stepping and
|
||||
* closing a photo, so the gate needs no network and no real token.
|
||||
* request the provider and MapillaryJS make while opening and closing a
|
||||
* photo, so the gate needs no network and no real token.
|
||||
*
|
||||
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
|
||||
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
|
||||
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
|
||||
north: 38.591,
|
||||
});
|
||||
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
|
||||
export const PHOTO_SPACING_M = 30;
|
||||
const PHOTO_SPACING_M = 30;
|
||||
/** Fixture thumbnail host (answered, never reached). */
|
||||
export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
||||
|
||||
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
|
||||
: [];
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('lat') && q.has('lng')) {
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
||||
const limit = Number(q.get('limit')) || 10;
|
||||
// In no particular order, as the real API answers.
|
||||
const data = photos
|
||||
.filter((image) => metresApart(at, image) <= radius)
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((image) => withFields(imageRecord(image), fields));
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||
return {
|
||||
...json(400, { error: 'unsupported images query' }),
|
||||
|
||||
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
|
||||
height: 700,
|
||||
pitch: -1.4,
|
||||
});
|
||||
export const PNX_CREATOR = 'Fixture Mapper';
|
||||
const PNX_CREATOR = 'Fixture Mapper';
|
||||
const PNX_SPACING_M = 10;
|
||||
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
|
||||
const API = 'https://api.panoramax.xyz/api';
|
||||
|
||||
+92
-37
@@ -2,8 +2,11 @@
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
"src/ui/styles/form-field.css",
|
||||
"scripts/qa-form-fields.mjs",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/qa-layer-visibility.mjs",
|
||||
"scripts/next-layer-state-token.mjs",
|
||||
"scripts/check-layer-state-tokens.mjs",
|
||||
"scripts/qa-layer-token-twochar.mjs",
|
||||
@@ -46,9 +49,6 @@
|
||||
"scripts/qa-voice-auth-focus.mjs",
|
||||
"src/codexOauthRealtime.test.mjs",
|
||||
"src/voice/cloudVoiceAuth.test.mjs",
|
||||
"scripts/qa-panel-resize.mjs",
|
||||
"scripts/qa-panelDrag.mjs",
|
||||
"src/tooling/panelResizeQa.test.mjs",
|
||||
"scripts/qa-terrain-429.mjs",
|
||||
"src/app/application.js",
|
||||
"src/app/application.test.mjs",
|
||||
@@ -96,6 +96,7 @@
|
||||
"server/providers/space/launch-library.js",
|
||||
"src/data/spaceProviderRequests.js",
|
||||
"src/tooling/spaceProviders.test.mjs",
|
||||
"src/tooling/ll2Integration.test.mjs",
|
||||
"server/providers/terrain.js",
|
||||
"server/providers/traffic.js",
|
||||
"server/providers/firms.js",
|
||||
@@ -114,11 +115,14 @@
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"src/data/cctvLithuania.test.mjs",
|
||||
"server/providers/cctv/taiwan.js",
|
||||
"src/data/cctvTaiwan.test.mjs",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
"src/data/cctvMissingNumbers.test.mjs",
|
||||
"server/providers/cctv/sources.js",
|
||||
"src/data/cctvSourcePins.test.mjs",
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
"server/providers/radio/constants.js",
|
||||
@@ -181,7 +185,6 @@
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/panelResize.test.mjs",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.test.mjs",
|
||||
@@ -191,7 +194,11 @@
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualInput.test.mjs",
|
||||
"scripts/qa-visual-input.mjs",
|
||||
"scripts/qa-scope-hud.mjs",
|
||||
"scripts/qa-segmented-controls.mjs",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.test.mjs",
|
||||
"src/ui/styles/segmented-control.css",
|
||||
"src/ui/displayControls.test.mjs",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/cameraOrientationControls.test.mjs",
|
||||
@@ -279,11 +286,11 @@
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs",
|
||||
"src/ui.js",
|
||||
"src/standalone/ui.js",
|
||||
"scripts/qa-action-buttons.mjs",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelPositionControls.test.mjs",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
@@ -301,6 +308,8 @@
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/slider.css",
|
||||
"src/ui/styles/action-button.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
@@ -445,7 +454,9 @@
|
||||
"src/data/satellites.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -508,7 +519,6 @@
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/data/traffic.js",
|
||||
"src/data/bikeshare.js",
|
||||
"src/data/flowTiles.js",
|
||||
"src/layers/traffic/source.test.mjs",
|
||||
"src/layers/bikeshare/source.test.mjs",
|
||||
"src/layers/bikeshare/proxyRoute.test.mjs",
|
||||
@@ -550,11 +560,14 @@
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvCataloniaSource.test.mjs",
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/layers/cctv/frames.test.mjs",
|
||||
"src/layers/cctv/rendering.test.mjs",
|
||||
"src/data/alprCameras.js",
|
||||
"src/data/alprCameras.test.mjs",
|
||||
"src/data/dataCredits.test.mjs",
|
||||
@@ -635,9 +648,9 @@
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"build/application-html.js",
|
||||
"src/app/sourceSlot.js",
|
||||
"src/app/sources.js",
|
||||
"src/ui/composition.js",
|
||||
"src/services/application.js",
|
||||
@@ -690,6 +703,7 @@
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/ui/panelChrome.js",
|
||||
"src/ui/panelChrome.radioOwnership.test.mjs",
|
||||
"src/testSupport/readShellSource.mjs",
|
||||
"src/ui/stateOwners.test.mjs",
|
||||
"src/layers/flights/records.js",
|
||||
@@ -858,6 +872,18 @@
|
||||
"src/layers/gnss/source.test.mjs",
|
||||
"src/layers/gnss/ownership.test.mjs",
|
||||
"scripts/qa-gnss.mjs",
|
||||
"server/providers/ghostMaps.js",
|
||||
"server/providers/ghostMaps.test.mjs",
|
||||
"src/app/layers/ghostMaps.js",
|
||||
"src/layers/ghostMaps/index.js",
|
||||
"src/layers/ghostMaps/catalog.js",
|
||||
"src/layers/ghostMaps/kml.js",
|
||||
"src/layers/ghostMaps/records.js",
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/layers/ghostMaps/catalog.test.mjs",
|
||||
"src/layers/ghostMaps/records.test.mjs",
|
||||
"src/layers/ghostMaps/lifecycle.test.mjs",
|
||||
"scripts/qa-ghostmaps.mjs",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
@@ -906,27 +932,32 @@
|
||||
"src/ui/streetLevelPresentation.test.mjs",
|
||||
"src/app/layers/streetLevel.js",
|
||||
"src/tooling/mapillaryProvider.test.mjs",
|
||||
"src/app/layers/streetLevel.test.mjs",
|
||||
"src/data/idSwitches.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||
"src/layers/streetLevel/providerSwitches.test.mjs",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/uiState.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
||||
"src/streetLevelMarkup.test.mjs",
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/layers/streetLevel/policy.js",
|
||||
"src/layers/streetLevel/filter.js",
|
||||
"src/layers/streetLevel/registry.js",
|
||||
"src/layers/streetLevel/params.js",
|
||||
"src/layers/streetLevel/pickRouter.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
"src/layers/streetLevel/groundCast.js",
|
||||
"src/layers/streetLevel/meshSampler.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/cameraFollow.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/state.js",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/glyphs.js",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
@@ -939,7 +970,6 @@
|
||||
"src/layers/streetLevel/providers/google/viewer.js",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||
"src/layers/streetLevel/groundClick.test.mjs",
|
||||
"src/app/layers/streetLevel.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/viewer.test.mjs",
|
||||
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
|
||||
@@ -949,8 +979,6 @@
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
|
||||
@@ -979,8 +1007,6 @@
|
||||
"src/testSupport/kartaViewFakes.mjs",
|
||||
"src/testSupport/googleStreetViewFakes.mjs",
|
||||
"server/providers/mapillary.js",
|
||||
"server/providers/mapillary/routes.js",
|
||||
"server/providers/mapillary/constants.js",
|
||||
"server/providers/mapillary/tiles.js",
|
||||
"server/providers/mapillary/trim.js",
|
||||
"src/layers/streetLevel/tileMath.test.mjs",
|
||||
@@ -993,23 +1019,15 @@
|
||||
"scripts/fixtures/street-level/kartaView.mjs",
|
||||
"scripts/fixtures/street-level/panoramax.mjs",
|
||||
"src/layers/streetLevel/filter.test.mjs",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||
"src/layers/streetLevel/uiState.test.mjs",
|
||||
"src/layers/streetLevel/groundCast.test.mjs",
|
||||
"src/layers/streetLevel/meshSampler.test.mjs",
|
||||
"src/layers/streetLevel/view.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||
"src/layers/streetLevel/cameraFollow.test.mjs",
|
||||
"src/layers/streetLevel/cameraFraming.test.mjs",
|
||||
"src/ui/streetLevelControls.test.mjs",
|
||||
"src/ui/panelDock.test.mjs",
|
||||
"src/layers/streetLevel/marker.test.mjs",
|
||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
|
||||
"src/layers/streetLevel/sequenceCones.test.mjs",
|
||||
"src/layers/streetLevel/selection.test.mjs",
|
||||
"src/layers/streetLevel/index.test.mjs",
|
||||
"scripts/qa-overpass-offload.mjs",
|
||||
@@ -1026,6 +1044,7 @@
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
"src/tools/catalog.test.mjs",
|
||||
"src/tools/area.test.mjs",
|
||||
"src/tools/results.test.mjs",
|
||||
"src/tools/queries/queries.test.mjs",
|
||||
"src/tools/mcp/mcp.test.mjs",
|
||||
"src/tools/localServer.test.mjs",
|
||||
@@ -1062,5 +1081,41 @@
|
||||
"src/ui/flyToDataSource.test.mjs",
|
||||
"scripts/assemble-changelog.mjs",
|
||||
"src/tooling/changelogFragments.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs"
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/data/routeSegment.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"scripts/build-bright-stars.mjs",
|
||||
"scripts/build-milky-way.mjs",
|
||||
"src/sky/stars.test.mjs",
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/statusBadge.test.mjs",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
"scripts/qa-status-badges.mjs",
|
||||
"src/dockerTrustedPeers.test.mjs",
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"src/voice/panel/preferences.test.mjs",
|
||||
"src/voice/panel/host.test.mjs",
|
||||
"src/voice/panel/manualPanel.test.mjs",
|
||||
"src/voice/panel/nativeActivity.test.mjs",
|
||||
"src/voice/panel/inactivity.test.mjs",
|
||||
"src/voice/panel/actionObserver.test.mjs",
|
||||
"src/voice/panel/testDom.mjs",
|
||||
"src/voice/realtimeCostPanel.test.mjs",
|
||||
"docs/SHARE-LINK-FORMAT.md",
|
||||
"src/share/wire.test.mjs",
|
||||
"src/share/viewCodec.test.mjs",
|
||||
"src/share/legacyShareState.test.mjs",
|
||||
"src/testSupport/shareViewFixtures.mjs",
|
||||
"src/ui/shareLinks.test.mjs",
|
||||
"src/ui/shareButton.test.mjs",
|
||||
"src/share/shareLinkPath.test.mjs"
|
||||
]
|
||||
+338
-86
File diff suppressed because it is too large.
Load diff
@@ -326,8 +326,10 @@ async function main() {
|
||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
|
||||
);
|
||||
let sidecar = await loadSidecar(output);
|
||||
// A cold catalog waits for its slowest pack (Tarktee gets 30 s), so allow
|
||||
// more than that.
|
||||
const response = await fetch(`${base}/api/cctv/sources`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
|
||||
const catalog = await response.json();
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scenes/CCTV action-button acceptance against dev or production preview:
|
||||
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
|
||||
* Uses the installed CCTV controller with fixture camera/action ports to prove
|
||||
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||
*/
|
||||
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();
|
||||
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++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const shots = 'qa-shots/action-buttons';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-new-btn',
|
||||
'scene-delete-btn',
|
||||
'scene-capture-btn',
|
||||
'scene-update-shot-btn',
|
||||
'scene-start-btn',
|
||||
'scene-stop-btn',
|
||||
'scene-next-btn',
|
||||
'scene-export-btn',
|
||||
'scene-import-btn',
|
||||
'scene-download-btn',
|
||||
'cctv-nearest-btn',
|
||||
'cctv-prev-btn',
|
||||
'cctv-next-btn',
|
||||
'cctv-focus-btn',
|
||||
'cctv-calib-save-btn',
|
||||
'cctv-calib-reset-btn',
|
||||
];
|
||||
const ordinary = ids.map((id) => document.getElementById(id));
|
||||
const generated = [
|
||||
...document.querySelectorAll(
|
||||
'.scene-shot-btn, [data-director-authoring] button',
|
||||
),
|
||||
];
|
||||
const toggles = [
|
||||
'cctv-enable-btn',
|
||||
'cctv-coverage-btn',
|
||||
'cctv-auto-hop-btn',
|
||||
'cctv-projection-btn',
|
||||
'cctv-adjust-btn',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
shared: [...ordinary, ...generated].every(
|
||||
(node) =>
|
||||
node?.type === 'button' &&
|
||||
node.classList.contains('gev-action-button'),
|
||||
),
|
||||
generated: generated.length,
|
||||
layout: ordinary.every((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return style.minWidth === '72px' && style.flexGrow === '1';
|
||||
}),
|
||||
togglesSeparate: toggles.every(
|
||||
(node) => node && !node.classList.contains('gev-action-button'),
|
||||
),
|
||||
compactDanger: !!document.querySelector(
|
||||
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'static and generated actions share the native component',
|
||||
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
'persistent CCTV toggles retain their existing controls',
|
||||
inventory.togglesSeparate,
|
||||
);
|
||||
check('ordinary actions retain their panel sizing', inventory.layout);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaButtonCalls = [];
|
||||
const record = (...args) => window.__qaButtonCalls.push(args);
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => {
|
||||
record('enable');
|
||||
return true;
|
||||
},
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
setParams: (params, options) => record('params', params, options),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
focusNearest: () => {
|
||||
record('nearest');
|
||||
return 'a';
|
||||
},
|
||||
cycleCamera: (direction) => {
|
||||
record('cycle', direction);
|
||||
return 'b';
|
||||
},
|
||||
focusCamera: (id, seconds) => record('focus', id, seconds),
|
||||
};
|
||||
window.__qaCameraState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', city: 'Fixture', name: 'A' },
|
||||
{ id: 'b', city: 'Fixture', name: 'B' },
|
||||
],
|
||||
activeCamera: { id: 'a', name: 'A', basePose: {} },
|
||||
};
|
||||
owner._renderCctvState(window.__qaCameraState);
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.focus('#cctv-next-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Space dispatches Next and its focus once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'native keyboard activation keeps a visible focus ring',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
document.activeElement === node &&
|
||||
getComputedStyle(node).outlineStyle === 'solid',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.focus('#cctv-prev-btn');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Enter dispatches Previous once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
|
||||
for (const [id, expected] of [
|
||||
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
|
||||
[
|
||||
'cctv-focus-btn',
|
||||
[
|
||||
['enable'],
|
||||
['focus', 'a', 1.9],
|
||||
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
|
||||
],
|
||||
],
|
||||
]) {
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click(`#${id}`);
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
const calls = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
`${id} retains its existing action and focus timing`,
|
||||
JSON.stringify(calls) === JSON.stringify(expected),
|
||||
calls,
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-calib-save-btn');
|
||||
await page.click('#cctv-calib-reset-btn');
|
||||
const calibration = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
'Save and Reset retain camera identity and user-origin dispatch',
|
||||
JSON.stringify(calibration) ===
|
||||
JSON.stringify([
|
||||
[
|
||||
'params',
|
||||
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
|
||||
{ origin: 'user' },
|
||||
],
|
||||
[
|
||||
'params',
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', reset: true },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
],
|
||||
]),
|
||||
calibration,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const node = document.getElementById('cctv-next-btn');
|
||||
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
check(
|
||||
'disabled actions stay inert and keep disabled presentation',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
node.disabled &&
|
||||
getComputedStyle(node).opacity === '0.45' &&
|
||||
window.__qaButtonCalls.length === 0,
|
||||
),
|
||||
);
|
||||
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const fit = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
count: buttons.length,
|
||||
fits:
|
||||
buttons.length > 0 &&
|
||||
buttons.every((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
return (
|
||||
box.width > 0 &&
|
||||
box.left >= rect.left &&
|
||||
box.right <= rect.right + 1 &&
|
||||
box.left >= 0 &&
|
||||
box.right <= innerWidth + 1 &&
|
||||
button.scrollWidth <= button.clientWidth
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
|
||||
if (width !== 620)
|
||||
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner.destroy();
|
||||
document.getElementById('cctv-next-btn').disabled = false;
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
check(
|
||||
'destroyed controllers revoke action listeners',
|
||||
await page.evaluate(() => window.__qaButtonCalls.length === 0),
|
||||
);
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const translated = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
buttons.length > 0 &&
|
||||
buttons.every((node) => node.scrollWidth <= node.clientWidth)
|
||||
);
|
||||
});
|
||||
check(`Russian ${panel} action labels fit at 390px`, translated);
|
||||
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-anime-sky — the anime style must leave the sky without bands.
|
||||
*
|
||||
* Anime's luminance bands turn the faint Milky Way into blotches, so
|
||||
* src/ui/visualEffects.js fades the glow out with the style. Headless against
|
||||
* a Vite dev server, both sky assets must be ready. Normal must show the
|
||||
* Galactic Centre glow (at least 2 % of pixels at luma >= 10/255), then
|
||||
* Anime must retain stars while at most 0.5 % reaches the first cel band.
|
||||
* The Normal baseline measured 43 % in headless Chromium at 1000×700;
|
||||
* the 2 % floor leaves room for GPU differences while rejecting a black sky.
|
||||
*
|
||||
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
|
||||
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const URL_ =
|
||||
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const host = new URL(request.url()).hostname;
|
||||
if (host.endsWith('halfpixel.ai') || host.includes('worldfx'))
|
||||
request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', String(e).slice(0, 300)));
|
||||
await page.setViewport({ width: 1000, height: 700 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed'),
|
||||
);
|
||||
await page.goto(URL_, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
|
||||
timeout: 120000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const sky = window.__godsEyeView.viewer.scene.skyBox;
|
||||
return (
|
||||
sky?._milkyWayCubeMap &&
|
||||
sky?._starCommand &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
);
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const r = await page.evaluate(async () => {
|
||||
const entry = performance
|
||||
.getEntriesByType('resource')
|
||||
.map((e) => e.name)
|
||||
.find((n) => /\.vite\/deps\/cesium\.js/.test(n));
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
'run against the Vite dev server (needs .vite/deps/cesium.js)',
|
||||
);
|
||||
const C = await import(entry),
|
||||
g = window.__godsEyeView,
|
||||
v = g.viewer,
|
||||
s = v.scene;
|
||||
g.styleManager.setStyle('normal', { userInitiated: true });
|
||||
const t = v.clock.currentTime;
|
||||
await C.Transforms.preloadIcrfFixed(
|
||||
new C.TimeInterval({
|
||||
start: C.JulianDate.addDays(t, -1, new C.JulianDate()),
|
||||
stop: C.JulianDate.addDays(t, 1, new C.JulianDate()),
|
||||
}),
|
||||
);
|
||||
const m = C.Transforms.computeIcrfToFixedMatrix(t);
|
||||
const ra = C.Math.toRadians(266.4),
|
||||
dec = C.Math.toRadians(-29);
|
||||
const look = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
new C.Cartesian3(
|
||||
Math.cos(dec) * Math.cos(ra),
|
||||
Math.cos(dec) * Math.sin(ra),
|
||||
Math.sin(dec),
|
||||
),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const north = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
C.Cartesian3.UNIT_Z,
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const right = C.Cartesian3.normalize(
|
||||
C.Cartesian3.cross(look, north, new C.Cartesian3()),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
s.camera.cancelFlight();
|
||||
s.tweens.removeAll();
|
||||
s.camera.setView({
|
||||
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
|
||||
orientation: {
|
||||
direction: look,
|
||||
up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
|
||||
},
|
||||
});
|
||||
const gl = s.context._gl,
|
||||
w = gl.drawingBufferWidth,
|
||||
h = gl.drawingBufferHeight,
|
||||
px = new Uint8Array(w * h * 4);
|
||||
const measure = async () => {
|
||||
await new Promise((res) => setTimeout(res, 2500));
|
||||
s.requestRender();
|
||||
s.render();
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||
let banded = 0;
|
||||
let visible = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
||||
if (luma >= 51) banded++;
|
||||
if (luma >= 10) visible++;
|
||||
}
|
||||
return { banded, visible };
|
||||
};
|
||||
const normal = await measure();
|
||||
g.styleManager.setStyle('anime', { userInitiated: true });
|
||||
const anime = await measure();
|
||||
let animeOn = false;
|
||||
for (let i = 0; i < s.postProcessStages.length; i++) {
|
||||
const stage = s.postProcessStages.get(i);
|
||||
if (stage.name === 'godsEyeView_anime')
|
||||
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
||||
}
|
||||
return {
|
||||
total: px.length / 4,
|
||||
normal,
|
||||
...anime,
|
||||
assetsReady: Boolean(s.skyBox._milkyWayCubeMap && s.skyBox._starCommand),
|
||||
visibility: s.skyBox.milkyWayVisibility,
|
||||
animeOn,
|
||||
};
|
||||
});
|
||||
const tag =
|
||||
!r.animeOn || !r.assetsReady
|
||||
? 'UNREACHED'
|
||||
: r.normal.visible >= 0.02 * r.total &&
|
||||
r.banded > 0 &&
|
||||
r.banded <= 0.005 * r.total &&
|
||||
r.visibility === 0
|
||||
? 'PASS'
|
||||
: 'FAIL';
|
||||
console.log(
|
||||
`${tag}: ${r.banded} of ${r.total} sky pixels at or above the first cel band (luma >= 0.2)\n ${JSON.stringify(r)}`,
|
||||
);
|
||||
process.exitCode = tag === 'PASS' ? 0 : tag === 'FAIL' ? 1 : 2;
|
||||
} catch (error) {
|
||||
console.error(`UNREACHED: ${error.message}`);
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Console messages and failed requests kept per page. */
|
||||
export const EVIDENCE_LIMIT = 200;
|
||||
const EVIDENCE_LIMIT = 200;
|
||||
|
||||
/** Cesium's console line when the render loop throws (CesiumWidget). */
|
||||
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
||||
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
||||
|
||||
/** Mask tokens in a URL or message before it is printed or saved. */
|
||||
export function redact(text) {
|
||||
|
||||
@@ -370,6 +370,9 @@ try {
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
// Phones present CCTV in the Cameras tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
|
||||
check(
|
||||
'narrow camera panel remains within the viewport',
|
||||
|
||||
+204
-70
@@ -11,8 +11,10 @@
|
||||
* exactly once per camera ACTIVATION (the §9.1 obstruction probe) and
|
||||
* never again in steady state; shared mesh-floor sampling fires at most
|
||||
* once per coarse cell during the staggered geometry-load drain, and
|
||||
* actually COMPLETES once tiles load (>=1 real sample — the
|
||||
* ceiling alone would pass trivially at Δ=0 if the snap never finished),
|
||||
* actually COMPLETES once tiles load in photoreal mode (>=1 real sample
|
||||
* — the ceiling alone would pass trivially at Δ=0 if the snap never
|
||||
* finished). Globe mode instead proves the rendered mount uses a real
|
||||
* terrain height with zero mesh samples. Sampling
|
||||
* stays flat while idle, and stays flat for non-positional pose edits.
|
||||
* 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane
|
||||
* exist for the active camera, the plane's corners coincide with the
|
||||
@@ -139,6 +141,56 @@ function record(name, ok, detail) {
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Each page.evaluate is bounded by puppeteer's `protocolTimeout` (default
|
||||
// 180s). Waits never come close: they poll from Node (below). The headroom is
|
||||
// for the few long evaluates, such as enabling the full ~3,700-camera catalog
|
||||
// on a real GPU, and matches the other qa-*.mjs harnesses.
|
||||
const PROTOCOL_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Polls an in-page predicate from Node, one short page.evaluate per tick,
|
||||
* until it holds or the budget runs out. Used instead of page.waitForFunction
|
||||
* for two reasons: its default rAF polling stops in a minimised or hidden
|
||||
* headful window (timers keep running, rAF does not), and its result is one
|
||||
* CDP call that protocolTimeout cuts off, while the headful drain budget
|
||||
* reaches ~50 minutes. A predicate that throws fails the run instead of
|
||||
* silently spending the budget.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {Function} predicate - In-page predicate.
|
||||
* @param {object} opts
|
||||
* @param {number} opts.timeoutMs - Overall budget.
|
||||
* @param {number} [opts.intervalMs=250] - Delay between checks.
|
||||
* @param {unknown[]} [opts.args=[]] - Arguments passed to the predicate.
|
||||
* The predicate must return a boolean: its result crosses CDP by value.
|
||||
* @returns {Promise<boolean>} True if the predicate held within the budget.
|
||||
*/
|
||||
async function waitForPredicate(
|
||||
page,
|
||||
predicate,
|
||||
{ timeoutMs, intervalMs = 250, args = [] },
|
||||
) {
|
||||
// performance.now() is monotonic and doesn't advance while the machine
|
||||
// sleeps, so neither a clock change nor a laptop sleep eats the budget.
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
if (await page.evaluate(predicate, ...args)) return true;
|
||||
} catch (err) {
|
||||
// A navigation or Vite reload replaces the execution context: poll again.
|
||||
if (
|
||||
!/Execution context was destroyed|Cannot find context/.test(
|
||||
err?.message ?? '',
|
||||
)
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) return false;
|
||||
await sleep(Math.min(intervalMs, remaining));
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftShader can spend tens of seconds servicing the hover drillPick emitted
|
||||
// by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the
|
||||
// pointer while ADJUST is disabled, then re-enable it and explicitly render a
|
||||
@@ -155,29 +207,58 @@ async function positionPointerForGizmoDrag(page, point, frameCount = 3) {
|
||||
gev.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.setParams({ calibrationMode: true });
|
||||
const scene = gev.viewer.scene;
|
||||
let rendered = 0;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (complete) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
return (await window.__qaRenderFrames(count, 5000)) >= count;
|
||||
}, frameCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a screenshot for human review. These are never a gate, so a capture
|
||||
* that fails (for example when the renderer stalls in a hidden headful window)
|
||||
* is logged instead of ending the run.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} file
|
||||
* @param {string} label
|
||||
*/
|
||||
async function saveReviewShot(page, file, label) {
|
||||
try {
|
||||
await page.screenshot({ path: file });
|
||||
console.log(` screenshot (${label}) → ${path.relative(REPO_ROOT, file)}`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
` screenshot skipped (${label}): ${path.relative(REPO_ROOT, file)}: ${err?.message ?? err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs window.__qaRenderFrames(count, capMs) in the page: requests renders
|
||||
* until `count` frames have passed scene.postRender or capMs runs out, and
|
||||
* resolves with the number of frames rendered. The cap keeps a stalled
|
||||
* renderer (headful window asleep or hidden) from hanging the CDP call.
|
||||
* @param {import('puppeteer').Page} page
|
||||
*/
|
||||
function installRenderFramesHelper(page) {
|
||||
return page.evaluate(() => {
|
||||
window.__qaRenderFrames = (count, capMs) =>
|
||||
new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let frames = 0;
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
resolve(complete);
|
||||
resolve(frames);
|
||||
};
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
rendered += 1;
|
||||
if (rendered >= count) {
|
||||
finish(true);
|
||||
if (++frames >= count) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
scene.requestRender();
|
||||
});
|
||||
const timer = setTimeout(() => finish(false), 5000);
|
||||
const timer = setTimeout(finish, capMs);
|
||||
scene.requestRender();
|
||||
});
|
||||
}, frameCount);
|
||||
});
|
||||
}
|
||||
|
||||
// In-page helper (stringified and re-defined inside each evaluate that needs
|
||||
@@ -280,8 +361,8 @@ const BEARING_SHIFT_SRC = `
|
||||
* @returns {Promise<boolean>} True if tilesLoaded was observed within the timeout.
|
||||
*/
|
||||
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
return page
|
||||
.waitForFunction(
|
||||
return waitForPredicate(
|
||||
page,
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
@@ -293,10 +374,8 @@ function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
}
|
||||
return true; // no tileset in the scene — nothing to wait for
|
||||
},
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
{ timeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,6 +443,10 @@ async function main() {
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||
// the length of a headful run.
|
||||
pipe: true,
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
@@ -397,13 +480,18 @@ async function main() {
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
const appReady = await waitForPredicate(
|
||||
page,
|
||||
() =>
|
||||
Boolean(
|
||||
window.__godsEyeView &&
|
||||
window.__godsEyeView.viewer &&
|
||||
window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 },
|
||||
),
|
||||
{ timeoutMs: 60000 },
|
||||
);
|
||||
if (!appReady) throw new Error('app did not initialise within 60s');
|
||||
await installRenderFramesHelper(page);
|
||||
// Let the initial fly-to Austin and first tiles settle.
|
||||
await sleep(4000);
|
||||
|
||||
@@ -475,18 +563,15 @@ async function main() {
|
||||
console.log(
|
||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||
);
|
||||
const drained = await page
|
||||
.waitForFunction(
|
||||
const drained = await waitForPredicate(
|
||||
page,
|
||||
() => {
|
||||
const mod =
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return ui.loading && ui.loading.active === false;
|
||||
return Boolean(ui.loading) && ui.loading.active === false;
|
||||
},
|
||||
{ timeout: drainBudgetMs },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
{ timeoutMs: drainBudgetMs, intervalMs: 1000 },
|
||||
);
|
||||
record(
|
||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||
drained ? true : HEADFUL ? false : null,
|
||||
@@ -575,18 +660,77 @@ async function main() {
|
||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||
// latency of the completion pass (update interval is 10s).
|
||||
// -----------------------------------------------------------------------
|
||||
const globeMode = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
||||
);
|
||||
if (globeMode) {
|
||||
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||
// Require independently fetched DEM heights and the actual rendered
|
||||
// mount, so a catalog/geoid fallback cannot turn zero samples into a pass.
|
||||
console.log(
|
||||
'Checking terrain-globe completion against live DEM heights...',
|
||||
);
|
||||
const terrain = await page.evaluate(async () => {
|
||||
const camera = window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const exact = `${camera.lon.toFixed(5)},${camera.lat.toFixed(5)}`;
|
||||
const cell = `${camera.lon.toFixed(3)},${camera.lat.toFixed(3)}`;
|
||||
const response = await fetch(
|
||||
`/api/terrain/heights?points=${encodeURIComponent(`${exact};${cell}`)}`,
|
||||
{ signal: AbortSignal.timeout(30000) },
|
||||
);
|
||||
const body = await response.json();
|
||||
return { ok: response.ok, results: body.results };
|
||||
});
|
||||
const heights = terrain.results?.map((result) => result?.ellipsoid);
|
||||
const terrainReady =
|
||||
terrain.ok && heights?.length === 2 && heights.every(Number.isFinite);
|
||||
const mountResolved =
|
||||
terrainReady &&
|
||||
(await waitForPredicate(
|
||||
page,
|
||||
(expected, base) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const camera = dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
|
||||
const mount = ray?.polyline?.positions.getValue(
|
||||
viewer.clock.currentTime,
|
||||
)?.[0];
|
||||
const height =
|
||||
mount &&
|
||||
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
|
||||
.height;
|
||||
return (
|
||||
Number.isFinite(camera.groundPriorM) &&
|
||||
Math.abs(camera.groundPriorM - expected[0]) < 0.01 &&
|
||||
Number.isFinite(height) &&
|
||||
expected.some(
|
||||
(ground) =>
|
||||
Math.abs(height - ground - camera.mountHeightM) < 0.05,
|
||||
) &&
|
||||
window.__qaCounters.sampleHeight === base
|
||||
);
|
||||
},
|
||||
{ timeoutMs: 30000, args: [heights, c0.sampleHeight] },
|
||||
));
|
||||
record(
|
||||
'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples',
|
||||
Boolean(mountResolved),
|
||||
`terrain=${JSON.stringify(heights)}, mesh sample Δ=${(await readCounters()).sampleHeight - c0.sampleHeight}`,
|
||||
);
|
||||
} else {
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded) {
|
||||
sampleFloorOk = await page
|
||||
.waitForFunction(
|
||||
sampleFloorOk = await waitForPredicate(
|
||||
page,
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeout: 30000 },
|
||||
c0.sampleHeight,
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
{ timeoutMs: 30000, args: [c0.sampleHeight] },
|
||||
);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
@@ -596,6 +740,7 @@ async function main() {
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
);
|
||||
}
|
||||
|
||||
// Let the completion pass finish before the steady-state window: require
|
||||
// the sampleHeight counter quiet for 12s (> the 10s update interval, so
|
||||
@@ -1358,18 +1503,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
window.__qaCctvSiblingOwner = owner;
|
||||
await new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
if (++frames < 3) {
|
||||
scene.requestRender();
|
||||
return;
|
||||
}
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
const framesRendered = await window.__qaRenderFrames(3, 10000);
|
||||
const rect = scene.canvas.getBoundingClientRect();
|
||||
const picked = scene.pick(canvasPoint);
|
||||
const pickedId = picked?.id?.id ?? picked?.id ?? null;
|
||||
@@ -1382,26 +1516,33 @@ async function main() {
|
||||
typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null),
|
||||
ownerId: owner.id,
|
||||
ownsExactObject: picked?.id === owner,
|
||||
framesRendered,
|
||||
projectionDelta: Math.hypot(
|
||||
projected.x - canvasPoint.x,
|
||||
projected.y - canvasPoint.y,
|
||||
),
|
||||
};
|
||||
}, emptyClickPoint);
|
||||
const siblingOwnsPick =
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject;
|
||||
// No frame at all within the 10s cap points at a stalled renderer rather
|
||||
// than at picking, though the harness can't rule out both. Fail it on the
|
||||
// real-GPU sign-off path; leave it inconclusive under headless GL. A slow
|
||||
// SwiftShader frame (1-2 of 3) still counts as a real miss.
|
||||
const siblingRendererStalled =
|
||||
siblingTarget && !siblingOwnsPick && siblingTarget.framesRendered === 0;
|
||||
record(
|
||||
'sibling test object owns its canvas pick',
|
||||
!siblingTarget
|
||||
? null
|
||||
: siblingOwnsPick ||
|
||||
(siblingRendererStalled && !HEADFUL ? null : false),
|
||||
siblingTarget
|
||||
? siblingTarget.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
: null,
|
||||
siblingTarget
|
||||
? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
: 'no previously verified empty canvas ray was available',
|
||||
);
|
||||
if (
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
) {
|
||||
if (siblingOwnsPick) {
|
||||
await page.mouse.click(siblingTarget.x, siblingTarget.y);
|
||||
await sleep(250);
|
||||
}
|
||||
@@ -1426,8 +1567,7 @@ async function main() {
|
||||
});
|
||||
record(
|
||||
'sibling canvas click passes through without CCTV selection or deselection',
|
||||
siblingTarget?.pickedId === 'qa-cctv-sibling-owner' &&
|
||||
siblingTarget.ownsExactObject
|
||||
siblingOwnsPick
|
||||
? siblingClick.activeId === clickEvidenceSetup.activeId &&
|
||||
siblingClick.transitions === siblingClick.baselineTransitions &&
|
||||
siblingClick.focusEvents === 0 &&
|
||||
@@ -1626,10 +1766,7 @@ async function main() {
|
||||
);
|
||||
|
||||
const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png');
|
||||
await page.screenshot({ path: viewshedShot });
|
||||
console.log(
|
||||
` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`,
|
||||
);
|
||||
await saveReviewShot(page, viewshedShot, 'visual review only');
|
||||
|
||||
// =========================================================================
|
||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||
@@ -1963,10 +2100,7 @@ async function main() {
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(1000);
|
||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(
|
||||
` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`,
|
||||
);
|
||||
await saveReviewShot(page, shot, 'visual review only, not a gate');
|
||||
|
||||
// No console errors during the whole exercise.
|
||||
record(
|
||||
|
||||
@@ -18,3 +18,30 @@ export function chromeExecutablePath({
|
||||
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
|
||||
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
|
||||
}
|
||||
|
||||
/** Restore an owned test window before capture: focus emulation alone does not
|
||||
* unminimize the native compositor, so CDP screenshots can otherwise stall. */
|
||||
export async function captureQaScreenshot(
|
||||
page,
|
||||
options,
|
||||
{ headful = true } = {},
|
||||
) {
|
||||
if (headful) {
|
||||
const session = await page.createCDPSession();
|
||||
try {
|
||||
const { windowId, bounds } = await session.send(
|
||||
'Browser.getWindowForTarget',
|
||||
);
|
||||
if (bounds.windowState === 'minimized') {
|
||||
await session.send('Browser.setWindowBounds', {
|
||||
windowId,
|
||||
bounds: { windowState: 'normal' },
|
||||
});
|
||||
}
|
||||
await page.bringToFront();
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
return page.screenshot(options);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* passed with the painter stubbed out — the number was right and nothing was
|
||||
* drawn. Every assertion here now comes from the RENDERED path instead:
|
||||
*
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui/shellFacade.js), the
|
||||
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
|
||||
* counts once the sweep, the arbiter and the placement solve have all run.
|
||||
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
|
||||
|
||||
@@ -34,10 +34,6 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
|
||||
@@ -52,7 +52,6 @@ try {
|
||||
id,
|
||||
value,
|
||||
);
|
||||
const text = () => page.$eval('#cesium-credits', (e) => e.innerText);
|
||||
const shot = async (name, osm, tiles) => {
|
||||
await sleep(300);
|
||||
const state = await page.$eval('#cesium-credits', (e) => {
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env node
|
||||
/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */
|
||||
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);
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
fs.mkdirSync('qa-shots/form-fields', { recursive: true });
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?welcome=0`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const selects = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
'radio-filter',
|
||||
'sdr-gain-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
ids: selects.map((node) => node.id),
|
||||
shared:
|
||||
selects.length === 6 &&
|
||||
selects.every((node) => node.classList.contains('gev-field')),
|
||||
native: selects.every(
|
||||
(node) =>
|
||||
node instanceof HTMLSelectElement && !node.hasAttribute('role'),
|
||||
),
|
||||
dark: selects.every(
|
||||
(node) => getComputedStyle(node).colorScheme === 'dark',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six panel dropdowns retain native select semantics and shared surface',
|
||||
inventory.shared && inventory.native,
|
||||
inventory,
|
||||
);
|
||||
check('native option menus keep the dark scheme', inventory.dark);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('scene-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
window.__qaFormChanges = [];
|
||||
document
|
||||
.getElementById('scene-select')
|
||||
.addEventListener('change', (event) =>
|
||||
window.__qaFormChanges.push(event.target.value),
|
||||
);
|
||||
});
|
||||
const ownerSetup = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaFormCalls = [];
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => true,
|
||||
setParams: (...args) => window.__qaFormCalls.push(args),
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
setParams() {},
|
||||
focusCamera: (id) => window.__qaFormCalls.push(['focus', id]),
|
||||
selectCamera: (id) => id,
|
||||
};
|
||||
owner._cctvState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
],
|
||||
activeCamera: {
|
||||
id: 'a',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
calibration: {},
|
||||
basePose: {
|
||||
headingDeg: 20,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
owner._renderCctvState(owner._cctvState);
|
||||
const input = document.getElementById('sdr-frequency-input');
|
||||
return {
|
||||
min: input.min,
|
||||
max: input.max,
|
||||
step: input.step,
|
||||
type: input.type,
|
||||
shared: input.classList.contains('gev-field'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'FM frequency retains native bounds, precision and shared surface',
|
||||
ownerSetup.min === '87.5' &&
|
||||
ownerSetup.max === '108' &&
|
||||
ownerSetup.step === '0.1' &&
|
||||
ownerSetup.type === 'number' &&
|
||||
ownerSetup.shared,
|
||||
ownerSetup,
|
||||
);
|
||||
const validity = await page.$eval('#sdr-frequency-input', (input) => {
|
||||
const original = input.value;
|
||||
input.value = '87.4';
|
||||
const below = input.validity.rangeUnderflow;
|
||||
input.value = '108.1';
|
||||
const above = input.validity.rangeOverflow;
|
||||
input.value = '98.55';
|
||||
const step = input.validity.stepMismatch;
|
||||
input.value = '';
|
||||
const empty = input.validity.valid;
|
||||
input.value = original;
|
||||
return below && above && step && empty;
|
||||
});
|
||||
check(
|
||||
'native numeric validity preserves underflow, overflow, steps and empty drafts',
|
||||
validity,
|
||||
);
|
||||
await page.select('#cctv-camera-select', 'b');
|
||||
await page.waitForFunction(() => window.__qaFormCalls.length === 2);
|
||||
const cameraSelection = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'camera dropdown retains selection, focus and user-origin dispatch',
|
||||
cameraSelection[0][0] === 'focus' &&
|
||||
cameraSelection[0][1] === 'b' &&
|
||||
cameraSelection[1][0].selectedCameraId === 'b' &&
|
||||
cameraSelection[1][1].origin === 'user',
|
||||
cameraSelection,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormCalls = [];
|
||||
document.getElementById('cctv-camera-select').disabled = true;
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
await page.focus('#cctv-camera-select');
|
||||
check(
|
||||
'disabled native selects cannot receive keyboard focus',
|
||||
await page.evaluate(
|
||||
() => document.activeElement.id !== 'cctv-camera-select',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('cctv-camera-select').disabled = false;
|
||||
});
|
||||
const begin = async (field) => {
|
||||
await page.evaluate((field) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._beginCctvCalValueEdit(
|
||||
document.querySelector(`[data-cal-field="${field}"]`),
|
||||
);
|
||||
}, field);
|
||||
};
|
||||
for (const [field, step, value] of [
|
||||
['heading', '0.1', '30.0'],
|
||||
['pitch', '0.1', '-10.0'],
|
||||
['fov', '1', '45'],
|
||||
['range', '1', '120'],
|
||||
['height', '1', '8'],
|
||||
['north', '0.1', '0.0'],
|
||||
['east', '0.1', '0.0'],
|
||||
]) {
|
||||
await begin(field);
|
||||
const result = await page.$eval('.cctv-cal-input', (input) => ({
|
||||
step: input.step,
|
||||
value: input.value,
|
||||
native: input.type === 'number',
|
||||
shared: input.classList.contains('gev-field'),
|
||||
label: input.getAttribute('aria-label'),
|
||||
focus: document.activeElement === input,
|
||||
}));
|
||||
check(
|
||||
`${field} editor retains precision, initial value and focus`,
|
||||
result.step === step &&
|
||||
result.value === value &&
|
||||
result.native &&
|
||||
result.shared &&
|
||||
!!result.label &&
|
||||
result.focus,
|
||||
result,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
check(
|
||||
'Escape cancels each field without a calibration write',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 0),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '100');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
const commit = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'Enter commits once against the frozen camera base',
|
||||
commit.length === 1 &&
|
||||
commit[0][0].calibration.patch.headingDeg === 80 &&
|
||||
commit[0][0].calibration.cameraId === 'a' &&
|
||||
commit[0][1].origin === 'user',
|
||||
commit,
|
||||
);
|
||||
await begin('north');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '12.5');
|
||||
await page.$eval('.cctv-cal-input', (input) => input.blur());
|
||||
const blur = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'blur commits a decimal offset exactly once',
|
||||
blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5,
|
||||
blur,
|
||||
);
|
||||
await begin('east');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'an empty numeric draft does not write calibration',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.type('.cctv-cal-input', '90');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId =
|
||||
'b';
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a stale camera editor cannot commit to the next camera',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
for (const lang of ['en']) {
|
||||
await page.evaluate((lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
}, lang);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
width: rect.width,
|
||||
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
|
||||
radius: getComputedStyle(node).borderRadius,
|
||||
};
|
||||
});
|
||||
});
|
||||
check(
|
||||
`${lang} fields fit their panels at ${width}px`,
|
||||
layout.every((item) => item.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `qa-shots/form-fields/${lang}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.lang = 'en';
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
});
|
||||
const cyber = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) => getComputedStyle(node).backgroundColor,
|
||||
);
|
||||
check(
|
||||
'Cyber keeps its native field theme',
|
||||
cyber === 'rgba(26, 35, 39, 0.96)',
|
||||
cyber,
|
||||
);
|
||||
await begin('heading');
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
window.__qaDetachedInput = document.querySelector('.cctv-cal-input');
|
||||
owner.destroy();
|
||||
window.__qaDetachedInput.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
check(
|
||||
'disposal removes the editor and prevents late commits',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('.cctv-cal-input') &&
|
||||
window.__qaFormCalls.length === 2,
|
||||
),
|
||||
);
|
||||
// Scene selection can restore application settings. Exercise it after the
|
||||
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
|
||||
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('#scene-select option')].some(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
),
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.accept('QA selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('#scene-select option').length > 1,
|
||||
);
|
||||
const targetSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'QA selection fixture',
|
||||
).value,
|
||||
);
|
||||
const baselineSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
).value,
|
||||
);
|
||||
await page.select('#scene-select', baselineSceneId);
|
||||
await page.waitForFunction(
|
||||
(id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id,
|
||||
{},
|
||||
targetSceneId,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormChanges = [];
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
// Typeahead is native even when headless Chrome does not drive OS popups.
|
||||
await page.keyboard.press('q');
|
||||
const sceneSelection = await page.evaluate(() => ({
|
||||
id: document.getElementById('scene-select').value,
|
||||
selected: window.__godsEyeView.sceneDirector._getSelectedScene().id,
|
||||
changes: window.__qaFormChanges,
|
||||
focus: document.activeElement.id,
|
||||
outline: getComputedStyle(document.activeElement).outlineStyle,
|
||||
}));
|
||||
check(
|
||||
'native typeahead reaches the Scene owner and keeps focus',
|
||||
sceneSelection.id === targetSceneId &&
|
||||
sceneSelection.selected === targetSceneId &&
|
||||
sceneSelection.changes.at(-1) === targetSceneId &&
|
||||
sceneSelection.focus === 'scene-select',
|
||||
sceneSelection,
|
||||
);
|
||||
check('keyboard focus remains visible', sceneSelection.outline === 'solid');
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
node.clientWidth > 0 &&
|
||||
node.getBoundingClientRect().width <=
|
||||
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||
)
|
||||
);
|
||||
});
|
||||
check('real Russian labels and fields fit at 390px', translated);
|
||||
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -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 <dir>.
|
||||
* 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);
|
||||
});
|
||||
@@ -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']];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
));
|
||||
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`);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,481 +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);
|
||||
};
|
||||
// Grow it from the corner once the restored window holds still, with the
|
||||
// pointer really on the handle (a press beside it selects page text).
|
||||
let chosen = await readPanel();
|
||||
const tried = [];
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
|
||||
attempt++
|
||||
) {
|
||||
await settle();
|
||||
const box = await readPanel();
|
||||
const corner = handlePoint(box, 'se');
|
||||
const hit = await page.evaluate(({ x, y }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
return {
|
||||
dir: node?.dataset?.dir || null,
|
||||
target: node
|
||||
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
|
||||
: 'nothing',
|
||||
};
|
||||
}, corner);
|
||||
tried.push(
|
||||
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
|
||||
);
|
||||
if (hit.dir === 'se') await drag(corner, 0, 200);
|
||||
chosen = await readPanel();
|
||||
}
|
||||
assert.ok(
|
||||
chosen.height > MIN_SIZE.height + 150,
|
||||
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
|
||||
);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -1291,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');
|
||||
@@ -2761,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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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: '<!doctype html><html><body></body></html>',
|
||||
});
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
+491
-2719
File diff suppressed because it is too large.
Load diff
@@ -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) => {
|
||||
|
||||
+57
-55
@@ -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
|
||||
@@ -122,8 +130,8 @@ async function main() {
|
||||
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.exitCode = 2;
|
||||
@@ -135,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',
|
||||
],
|
||||
@@ -144,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 });
|
||||
@@ -194,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 ─────────────────────────────
|
||||
@@ -227,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');
|
||||
@@ -235,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,
|
||||
@@ -268,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 },
|
||||
@@ -316,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;
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Camera-jump regression against an existing server; never starts a server. */
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
+62
-17
@@ -36,10 +36,11 @@ import {
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
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,
|
||||
@@ -47,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';
|
||||
@@ -266,6 +265,7 @@ const browser = await puppeteer.launch({
|
||||
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,9 +292,11 @@ page.on('request', (request) => {
|
||||
});
|
||||
|
||||
const shot = (name, caption) =>
|
||||
page
|
||||
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
|
||||
.then(() => {
|
||||
captureQaScreenshot(page, {
|
||||
path: `${SHOTS}/${name}`,
|
||||
type: 'jpeg',
|
||||
quality: 82,
|
||||
}).then(() => {
|
||||
console.log(` shot ${name} — ${caption}`);
|
||||
});
|
||||
|
||||
@@ -391,22 +397,42 @@ try {
|
||||
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, city);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window.__godsEyeView.dataManager.layers
|
||||
const arrived = await page
|
||||
.waitForFunction(
|
||||
(feedId) =>
|
||||
[
|
||||
...window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module.getStats().count || 0) > 0,
|
||||
.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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Loaded 100 of 807 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user