Compare commits

..
Author SHA1 Message Date
Sameh Khamis 2cac463c6f Changelog: CCTV opening camera 2026-11-07 03:52:54 -08:00
Sameh Khamis 479286a6a6 CCTV opens on the camera nearest the viewer, not the first in the catalog
Turning CCTV on picked the first catalog record, so at King's Cross it
showed a Sydney camera. enable() now picks the camera nearest the viewer.
Until someone chooses a camera, each settled move re-picks the nearest,
so a share link whose flight lands after CCTV turned on still opens
nearby.
2026-11-07 03:52:38 -08:00
Sameh Khamis c0a5d4141d Merge pull request #1616 from bilawalsidhu/fix/chat-tool-schema-patterns
fix(chat): send OpenAI chat tool schemas Chat Completions accepts
2026-11-06 22:21:05 -08:00
Sameh Khamis 8ec92eb48c docs(changelog): OpenAI chat tool patterns (#1616) 2026-11-06 21:52:03 -08:00
Sameh Khamis 5e42a83d88 fix(chat): send OpenAI chat tool schemas Chat Completions accepts
OpenAI refused every typed chat request with 400 invalid_function_parameters:
resolve_area's area-id pattern uses Unicode property classes (\p{L}), which
Chat Completions does not accept as a regex. OpenAI chat now leaves such
patterns out of the tool schemas it sends; each action still checks its
arguments against its own contract, and realtime voice keeps the patterns.
2026-11-06 21:51:46 -08:00
Sameh Khamis 6e4da23cf7 Merge pull request #1609 from bilawalsidhu/feat/traffic-lines-toggle
Street Traffic: Lines and Dots toggles; hosts choose which road modes they offer
2026-11-06 13:46:00 -08:00
Sameh Khamis 574430fb1d docs(changelog): Street Traffic Lines and Dots toggles (#1609) 2026-11-06 11:44:17 -08:00
Sameh Khamis 9c8bb206ef feat(traffic): Lines and Dots toggles; hosts choose which road modes they offer
createTrafficSource({ roadModes }) lets a host offer a subset of the TomTom,
OSM and Hybrid road modes (all three by default). Without OSM the layer never
draws simulated roads, and a single mode shows no road chip.

While flow is live the Street Traffic row offers Lines (every road with flow
as a steady green/amber/red line, from the heat-line renderer) and Dots (the
moving dots), saved in layer state and share links. Defaults: Lines off,
Dots on.
2026-11-06 11:37:57 -08:00
Mazeyar Moeini Feizabadi c56d5f2f06 Merge pull request #1578 from bilawalsidhu/german/space-missions-failed-entry
Roll back a Space Missions entry whose mission index fails to load

Thanks @gekh. Maintainer review fixes (separate commits): exit cancels the layer's own camera flights again, a failed index load shows a specific toast, tests and docs.
2026-11-06 20:26:55 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 8da8c05eaf test(launches): keep the fixture's toast recorder for the failure-message test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:10:34 +01:00
Mazeyar Moeini Feizabadi 5dd7f41436 Merge branch 'pr-1578-new' into review/pr-1578
# Conflicts:
#	changelog.d/1578-space-missions-failed-entry.md
#	docs/CURRENT-STATE.md
#	src/layers/launches/controls.js
2026-11-06 20:10:01 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 efa4374644 fix(launches): hand the camera over before stopping, and only blame a fresh index error
- releaseCameraOwnership marks the camera released before stopping, so a
  flight navigation asked to preserve survives; exit also releases it, so an
  inactive layer never cancels another owner's flight.
- The failure toast names the mission index only when its error is from the
  current Context attempt (stats.errorAt), not an earlier one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:28:56 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 13219828e3 fix(launches): cancel mission flights on exit and name a failed index load
Review fixes on top of #1578:
- Exiting Space Missions cancels the layer's own camera flights again (entry,
  roster preview, mission focus); only a flight navigation started after
  releaseCameraOwnership() runs on.
- A failed first index load says so: "Space Missions unavailable: the
  mission index could not load. Try again later." on both the Context and
  Data Layers routes, instead of a generic transition toast. Translated.
- Regression tests for both; docs and changelog describe the real feedback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:20:11 +01:00
German Khokhlov 096b891099 Simplify failed-entry camera handling and tests 2026-11-06 22:01:39 +04:00
Mazeyar Moeini Feizabadi 3cd7ed68aa Merge remote-tracking branch 'origin/main' into review/pr-1578 2026-11-06 18:55:07 +01:00
Mazeyar Moeini Feizabadi 4effadd3e9 Merge pull request #1601 from bilawalsidhu/fix/street-level-keyless-default
fix(street-level): without a Mapillary key, start on a keyless source

Thanks @asturksever. Maintainer review fixes (separate commits): filter-only updates keep an explicitly chosen source, keyless Street Level QA gate updated, tests and docs.
2026-11-06 18:45:17 +01:00
Mazeyar Moeini Feizabadi ddc14c8f51 Merge remote-tracking branch 'origin/main' into review/pr-1601 2026-11-06 18:32:31 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 f7b9a2e5af test(street-level): keyless QA branch handles a keyed bundle behind a keyless server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:20:47 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 8a7402d80c fix(street-level): keep explicit provider choices on filter-only updates
Review fixes on top of #1601:
- Only redirect a provider the params switch on to a keyless source, so a
  filter-only setParams (360°, SINCE) no longer moves an explicitly chosen
  keyless Mapillary to KartaView.
- Keyless QA gate: assert the new KartaView default and that the Mapillary
  chip explains its key instead of switching (it used to expect KEY REQUIRED).
- Tests: Graph-proxy keyReady, filter-only updates; shared test helper.
- README and CURRENT-STATE describe the keyless default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:14:16 +01:00
German Khokhlov 72e2eba70d Merge pull request #1581 from bilawalsidhu/german/i18n-new-strings-fixes
Fix the translations of the voice geometry, fire filter, airspace and sea mark strings
2026-11-06 21:03:09 +04:00
German Khokhlov 41cc57d630 Merge branch 'main' into german/i18n-new-strings-fixes 2026-11-06 20:55:36 +04:00
Mazeyar Moeini Feizabadi 61dfdc771e Merge remote-tracking branch 'origin/main' into review/pr-1601 2026-11-06 17:54:23 +01:00
Mazeyar Moeini Feizabadi b35c810284 Merge pull request #1528 from dylanmaniatakes/codex/s2-ghostmaps-age-filter
feat: add adjustable age filtering across S2 GhostMaps

Thanks @dylanmaniatakes. Maintainer review fixes (separate commits): age-window narrowing/restore consistency, card kept across same-report reloads, date-only events cover their whole UTC day, and the age choice as a labelled Event age switch.
2026-11-06 17:52:28 +01:00
Mazeyar Moeini Feizabadi d5d824f705 Merge remote-tracking branch 'origin/main' into review/pr-1528 2026-11-06 17:40:33 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 91ab9b5811 fix(ghostmaps): follow-up review fixes for the age switch
- Keep the open card across a widened reload only when its index still
  holds the same report; the server may answer a pinned version with newer
  bytes.
- Narrowing to a window the loaded data already covers clears a failed
  wider reload, so the row no longer stays in error.
- Label the every-age option "All" (contextual) so the six-option switch
  fits the row in every locale; tooltip and info line keep "All markers".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:37:29 +01:00
German Khokhlov d544613aae Merge pull request #847 from kvnloo/exp/cyclone-shared-context
feat(cyclones): publish explicit storm selection to shared context
2026-11-06 20:35:22 +04:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 c4298c4450 fix(ghostmaps): keep age-window changes consistent and make the age choice a switch
Review fixes on top of #1528:
- Narrowing the age window only removes markers; a product loaded with a
  narrower window is reloaded when the window widens again. Fixes markers
  missing after 7d -> 1d -> 7d and avoids reparsing every KMZ on narrowing.
- A wider window over the same edition keeps the open report card.
- Date-only event dates cover their whole UTC day, so 1d still shows
  yesterday's reports.
- The age options are one labelled "Event age" radio switch (row
  `segmented` control, as FIRMS confidence) instead of toggle chips mixed
  with the product chips.
- Un-nest the per-product age test; QA compares data-source counts with the
  live defaults instead of a hard-coded 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:25:01 +01:00
German Khokhlov 8917eebe12 Tighten failed-entry comments and docs 2026-11-06 20:20:11 +04:00
German Khokhlov 5bab5ec26d refactor(cyclones): drop defensive shared-context state and checks 2026-11-06 20:15:27 +04:00
German Khokhlov 5e64e599e1 docs(cyclones): tighten shared-context comments, changelog and notes 2026-11-06 20:07:56 +04:00
Mazeyar Moeini Feizabadi 7673351ce0 Merge remote-tracking branch 'origin/main' into review/pr-1528
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-06 17:02:29 +01:00
German Khokhlov e1ea524b68 Merge main into german/space-missions-failed-entry 2026-11-06 20:00:21 +04:00
German Khokhlov ea22d16c1a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 19:44:54 +04:00
German Khokhlov 4e518821b5 fix(cyclones): name the classification and mark a stale cache in shared context
- Publish classificationName next to the raw NHC code; unknown codes stay
  unexpanded.
- Publish staleCacheFetchedAt only while the proxy serves its cache.
- Touch the shared store on clear only after this layer published, so a
  feed failure no longer evicts another layer's dangling selection.
- Drop the unreachable no-storm branch and the unused return value.
- Tests: split the heading fixture into due north and stationary, cover
  the unavailable and failure paths with the real store, and check that
  clear/disable leave another layer's selection alone; fold the fake-store
  test into the real-store one.
2026-11-06 19:43:04 +04:00
Said Turksever 3d6423c6f8 Merge pull request #870 from waar19/perf/fleet-tick-skip-hidden
perf(flights): skip dead-reckoning contacts that stay behind the horizon
2026-11-06 15:03:33 +00:00
Said Turksever 15fdf65a73 Merge branch 'main' into perf/fleet-tick-skip-hidden 2026-11-06 14:52:56 +00:00
Said Turksever 39d47a99ab Merge pull request #1585 from mertkayacs/fix/keyless-unicode-search
fix(geocode): preserve non-Latin names in keyless search
2026-11-06 12:25:44 +00:00
Said Turksever 3f152a71a1 Merge branch 'main' into fix/keyless-unicode-search 2026-11-06 12:09:03 +00:00
German Khokhlov 39ff131d60 Merge pull request #953 from trygvehhh/feat/datacenters-norway-refresh
data: refresh Norway datacenters from a Geofabrik OSM extract
2026-11-06 16:08:18 +04:00
German Khokhlov aca5317a2a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 16:03:13 +04:00
Said Turksever 141181f83a docs(flights): changelog names the one-in-13-ticks rotation, about once a second 2026-11-06 12:02:30 +00:00
Said Turksever dda5ac6c00 Changelog: 1601 fragment 2026-11-06 11:59:31 +00:00
German Khokhlov c058ecf21b docs(infrastructure): keep the dataset scope note 2026-11-06 15:58:12 +04:00
German Khokhlov e073e4dbe4 Merge branch 'main' into feat/datacenters-norway-refresh 2026-11-06 15:55:23 +04:00
German Khokhlov a69d0492d0 docs(datacenters): tighten the Norway refresh notes 2026-11-06 15:54:53 +04:00
Said Turksever 0dbbd9047e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-06 11:54:29 +00:00
Said Turksever acc4e63ec4 Merge branch 'main' into fix/keyless-unicode-search 2026-11-06 11:53:32 +00:00
Said Turksever 579acf5081 Street Level: without a Mapillary key, light a keyless source instead of opening on KEY REQUIRED 2026-11-06 11:51:34 +00:00
German Khokhlov 5d8f2b878f chore(datacenters): drop the one-off Norway refresh script
The data refresh is committed and its provenance (extract, timestamp, MD5,
filter) stays in the dataset README. The script needed osmium-tool and could
only re-verify while Geofabrik still listed that extract, so it goes with its
test.
2026-11-06 15:47:36 +04:00
German Khokhlov 0de347c328 Leave other camera flights running when Space Missions stops without a replay 2026-11-06 14:45:36 +04:00
Sameh Khamis a33bc379cb Merge pull request #1595 from bilawalsidhu/fix/traffic-flow-error-labels
Traffic: name an account's traffic limit instead of a daily budget
2026-11-05 21:29:09 -08:00
Sameh Khamis 58adbb11c7 fix(traffic): name an account's traffic limit instead of a daily budget
A failed flow tile now carries the server's reason. A hosted deployment that limits each account answers USAGE_LIMIT_REACHED or RATE_LIMITED, which the traffic panel labels as the monthly allowance or a rate limit; a 503 with another reason reads as the traffic service being unavailable, not a missing key. The local proxy's no_key and daily budget labels are unchanged.
2026-11-05 21:21:26 -08:00
Sameh Khamis ad009ea1eb Merge pull request #1591 from lyehe/pr/wildlife-sightings
feat: Wildlife Sightings layer from iNaturalist and Acartia
2026-11-05 17:32:05 -08:00
Sameh Khamis 93d495b0e5 Merge branch 'main' into pr/wildlife-sightings 2026-11-05 17:24:06 -08:00
Sameh Khamis 3650dd818b Merge pull request #1532 from manjunath22466/fix/globe-free-look-orbit
fix(camera): keep the globe in view from orbit
2026-11-05 17:23:37 -08:00
Sameh Khamis d807c3007e Merge branch 'main' into pr/wildlife-sightings 2026-11-05 17:15:10 -08:00
Sameh Khamis fcad75a7b6 Merge remote-tracking branch 'origin/main' into fix/globe-free-look-orbit
# Conflicts:
#	src/cameraVerbs.js
2026-11-05 17:08:07 -08:00
Sameh Khamis d00ced38bf Merge pull request #1520 from manjunath22466/manjunath/hal-695-voice-tilt
Fix voice camera tilt from a top-down view
2026-11-05 17:07:42 -08:00
Sameh Khamis 4951393997 Keep the place in view when a late zoom out catches a tilt
From 400 km, a tilt to -5° that a zoom out x2.5 caught 70 frames in eased down from the camera's old pitch at the new range. That put the camera below the place's horizon: the first frame's crosshair hit the ground 186 km from London, and London stayed hidden for about 11 frames. The re-solve also left the pivot pitch at 0°, so a zoom in before the next frame computed an infinite clearance floor, was refused, and stayed at 1,000 km.

The re-solved pivot pitch now stops at the place's horizon limit, or where the pivot already was. A camera that the zoom leaves past that limit steps down to it with the zoom and holds it, and the zoom still reports that angle. A zoom in straight after brings the range back to 400 km.
2026-11-05 16:55:54 -08:00
Sameh Khamis 6d7fe53570 Let a shell without its navigation owner report no camera handoffs
The voice runner subscribes to camera handoffs as it is created. The Cyber sonar test builds its runner on a StyleManager made from the prototype, which has no navigation owner, so the facade's delegation threw and both Node jobs failed. The facade now returns nothing there, like its other optional owners; the real shell builds its navigation owner before voice creates a runner.
2026-11-05 16:55:54 -08:00
Sameh Khamis dac9b5627d fix(wildlife): wait for the longest effective delay among every failed request
One error was chosen before each failure's delay was known. An antimeridian
box 400 hid the other box's 503 Retry-After; a grid tile 400 with Retry-After
set a cooldown through the snapshot metadata; a short Retry-After on one tile
undercut a sibling 5xx's default backoff; and a huge Retry-After made the
deadline Infinity.

Every failed request now keeps its status and Retry-After. One function gives
each failure's delay: 4xx other than 429 none, 429 and 5xx their Retry-After
or the default backoff. The layer waits for the longest, and shows the
failure that set it. Retry-After is capped at one day, the proxy's own
daily-budget deadline, and non-finite values are ignored. Each publish of
one acquisition derives the default backoff from the backoff before it
started, so repeated tile publishes no longer double it.
2026-11-05 16:42:45 -08:00
Sameh Khamis 3438a88cc7 Ease a late zoomed tilt to its new limit instead of jumping there
From 400 km, a tilt to -5° that a zoom out x2.5 caught at -8.0005° reported -8°, and the next frame jumped to -9.407° and ended there. The zoom kept the camera's pitch as the goal when it was already past the new range's horizon limit, but at the same pivot pitch the longer range looks down more steeply, so the next lookAt dropped the camera 1.4° at once.

The zoom now re-solves the pivot pitch so the new range keeps the camera's pitch, and aims the tilt at the asked angle or the new limit, even when that limit is below where the camera stands: the tilt eases down to it. The zoom's reply names that end, and the camera reaches it with no frame moving more than 0.1°.
2026-11-05 16:31:46 -08:00
Sameh Khamis 42fa974ee3 fix(wildlife): keep each failed source's retry timing after a partial failure
A partial failure reset the layer's cooldown, so a camera move retried at
once. Acartia's error was discarded, an absent X-Upstream-Status hid the
HTTP 503 (the proxy's daily-budget reply), and a grid schema error masked
the observation 503. Each failed source now keeps its status and
Retry-After, the HTTP status counts when no upstream status is sent, and the
layer waits for the longest 429/5xx cooldown among them. The shown error is
the one that explains the cooldown.
2026-11-05 16:27:19 -08:00
Sameh Khamis f332150689 fix(camera): hold the globe recovery until every pointer is released
The held-pointer guard was one flag, so with two fingers down, lifting one cleared it and the recovery turned the view down under the other (pitch -42° to -90°). It now tracks each pressed pointer by id and defers until the last is released.

A release outside the window that the page never sees could leave the recovery deferred for good. A lost window focus, a hidden page or a lost pointer capture now releases every held pointer, and a pointer that moves with no button down is released.
2026-11-05 16:16:05 -08:00
Sameh Khamis 198c2faf4f Say the -45° tilt default in the pitchDeg description again
The trim that fitted the tilt wording under the Realtime prompt ceiling dropped the -45° default from pitchDeg, so the model no longer read why a bare tilt goes oblique. pitchDeg reads 'Absolute; -45 default, -89 top-down.' again, and the direction description becomes 'Pan/rotate/tilt steps; orbit/rotate_globe default right.' to pay for it: the Overpass prompt measures 55,499, 1 under the 55,500 ceiling. The schema pin and the measured-size note follow.
2026-11-05 16:14:27 -08:00
Sameh Khamis 6479167b50 Pivot a tilt at exactly 1,000 km and report where a zoom now ends it
A pivot range rebuilt from the camera's position came back a hair over the 1,000 km cutoff when the view sat exactly on it, so a tilt from 1,000 km ran in place and lifted the crosshair onto the sky. The cutoff now allows 1 m of slack.

A zoom during a tilt to an angle changes the range the pivot holds, and with it the place's horizon limit, but the tilt kept its old goal and its reply its old angle: from 400 km, a tilt to -5° zoomed out x2.5 reported -5° with no note and ended at -9.4°. The zoom now re-judges the goal at the new range, eases the tilt from where it is to it, and its result names the angle the tilt now ends at, with a note.
2026-11-05 16:14:27 -08:00
Sameh Khamis fb0a58ef3c docs(wildlife): cite the documented iNaturalist UTFGrid endpoint and Acartia's key request
/grid/{z}/{x}/{y}.grid.json is documented in the v1 and v2 API docs as
Grid Tiles UTFGrid; only its per-cell records are unspecified, which is
what the schema guard covers. DATA_SOURCES.md now also notes that
Acartia's README asks API users to create an account and an API key,
although /sightings/current answers without one today.
2026-11-05 16:07:48 -08:00
Sameh Khamis 5391687d46 fix(wildlife): draw what arrived when one sighting source fails
An iNaturalist rejection discarded a successful Acartia reply, and the
antimeridian Promise.all lost both halves when one failed. Each box now
settles independently; the failed part is reported on
sources.inaturalist (error and failed boxes), the layer keeps that
area's last iNaturalist rows and shows DEGRADED with retry timing, and
the update fails outright only when every requested source failed.
2026-11-05 16:06:41 -08:00
Sameh Khamis 3cb30c3f97 fix(wildlife): point the Acartia card link only at acartia.io
The card's "Acartia" link was built from the contributor-supplied
photo_url, so any https site could appear labelled as Acartia. Acartia
has no documented per-sighting page, so the source link is now always
the Acartia site, on the server projection and again in the card.
The separate "Open on Acartia" fallback link is no longer needed.
2026-11-05 16:05:02 -08:00
Sameh Khamis 82138478d0 fix(wildlife): treat a valid empty Acartia snapshot as a fresh success
A quiet or fully filtered feed raised invalid_snapshot, so the proxy
answered 502 with a 15 s cooldown and re-polled about four times a
minute. Only a non-array payload is malformed now; an empty projection
caches for the normal ten minutes and clears earlier rows.
2026-11-05 16:03:52 -08:00
Sameh Khamis bd99000433 Merge remote-tracking branch 'origin/main' into pr/wildlife-sightings 2026-11-05 16:00:37 -08:00
Sameh Khamis 44106b2bfb fix(camera): hold the globe recovery while a pointer is down
Holding a drag still for half a second on a view that already showed no surface looked like a camera at rest, so the recovery turned the view down under the user's hand. The recovery now defers while a pointer pressed on the canvas is held, and runs once it is released, as for a flight or a playing scene.
2026-11-05 15:55:52 -08:00
Sameh Khamis a20076fc5e fix(camera): judge a voice tilt's frame limit by the way its pitch moves
The pre-check picked the in-place look from args.direction alone, while the tilt itself turns down for any direction but up. A tilt sent as left or right at the frame limit was checked as a turn left or right, refused as losing the globe, though it turns down toward the planet. A rotate sent as up or down was checked as a look up or down, though it turns right.

A tilt's look now comes from its goal pitch against the current pitch (tiltLook), and the tick tilts in place through tiltInPlaceWithinFrame(cam, m, goalPitch). Neither depends on the direction word, so a tilt to an absolute angle, or a tilt with no direction, can use them as they are.
2026-11-05 15:55:52 -08:00
Sameh Khamis f53342932d Merge branch 'main' into fix/globe-free-look-orbit 2026-11-05 15:52:21 -08:00
Sameh Khamis dae95e1c7f Refuse a motion after its reply's fly-to found nothing, also on Gemini
Gemini runs one turn's calls one after another. By the time "pan left" ran beside "fly to Xyzzyville", the lookup had already found nothing, so there was no flight to wait on and the pan moved the old view with ok: true. OpenAI's parallel calls refused the same reply.

The runner now remembers, for the latest reply that sent a voice fly-to, whether one moved the camera and whether one found nothing. A tilt, orbit, pan or rotate of that reply with no flight left to wait on is refused when its fly-to found nothing and none moved. The reply is Gemini's turn, passed as a per-turn reply object, or OpenAI's authorityGroup. A motion in a later reply, or beside a fly-to of the same reply that did land, runs as before.
2026-11-05 15:51:14 -08:00
Sameh Khamis 1eef14454a Drop a queued voice motion when another navigation takes the camera mid-flight
A motion queued behind a voice fly-to checked only that its reply was current and that the user had not touched the map. A location-panel search, share link or file import stamps a deferred navigation without stopping the flight, so the flight landed, the motion took navigation, and the search's reassert then failed: the search was silently dropped while the camera kept orbiting.

The runner now counts camera handoffs from subscribeCameraHandoff, less the ones voice fly-tos make themselves, and each voice flight records that count when it starts. A motion whose flight saw another navigation since then is not run and says so. Voice fly-tos' own stamps do not count, so a later fly-to in the same reply, including a lookup that finds nothing, is still followed as before. The test harness now publishes handoffs and stamps its voice lookups, as the shell does.
2026-11-05 15:48:21 -08:00
Sameh Khamis c4910d24d0 Keep the place centred when a far tilt reaches the horizon
From 550 to 1,000 km of range the camera's -5° limit lies past the place's own horizon. The swing ran into its 150 m clearance, let go of the pivot and tilted the rest in place, which lifted the crosshair onto the sky (28 to 63 km up from 600 to 900 km), and a return to top-down then landed hundreds of km from the place.

The swing now hands over to an in-place tilt only where the clearance is what stops it (within about 17 km). Farther out it stops with the camera 0.5° above the place's horizon, still pivoting: a tilt to an angle reports the angle it reached with a note, a continuous tilt stops there, and a further tilt up is refused as at the limit. Tests check that the crosshair stays on the place, frame by frame for a continuous tilt, and that top-down returns over it.
2026-11-05 15:43:51 -08:00
Sameh Khamis 395eba4548 Fit the tilt wording under the Realtime prompt ceiling
With main merged, the Overpass prompt measured 55,549 against the 55,500 ceiling. The pitchDeg and direction descriptions now read 'Absolute; -89 top-down.' and 'Needed for pan/rotate/tilt steps', bringing it to 55,497. The schema pin and the measured-size note follow.
2026-11-05 15:38:32 -08:00
Sameh Khamis 8687512fc8 Merge branch 'main' into manjunath/hal-695-voice-tilt
Re-derive the Realtime schema pin for main's control_cockpit wording and this branch's pitchDeg.
2026-11-05 15:37:50 -08:00
Sameh Khamis 2470b39f88 Merge pull request #1556 from ZloyS/denis/gev-cockpit-entry
feat(cockpit): enter Cockpit with only the ridden aircraft's layer on
2026-11-05 14:11:28 -08:00
Said Turksever d7856109c7 Merge branch 'main' into fix/keyless-unicode-search 2026-11-05 22:08:58 +00:00
Yehe Liu 4423f4b4a6 Merge branch 'main' into pr/wildlife-sightings 2026-11-05 17:04:06 -05:00
Sameh Khamis e3f668831c Merge branch 'main' into denis/gev-cockpit-entry 2026-11-05 14:01:31 -08:00
Sameh Khamis 63fd559496 Keep the ridden aircraft's layer when Contacts ends, and clear its rows from Live Signals
Riding an aircraft now adopts its layer the way a direct selection does, and
Contacts gives up a dependency the user adopts, so ending Contacts mid-session
(one of its layers off, or Contacts itself) no longer turns off the ridden
layer and ends the Cockpit. When Contacts goes off, Live Signals drops the
rows built from its snapshot instead of leaving stale distances marked
CURRENT, and the feed-off warning is no longer duplicated.
2026-11-05 14:01:17 -08:00
Sameh Khamis 0ff48ba059 Merge pull request #1546 from manjunath22466/fix/chat-provider-switch-keeps-context
fix(voice): keep chat's view across a provider switch and clear stale errors
2026-11-05 13:50:48 -08:00
Said Turksever bd8ef09042 Merge branch 'main' into fix/keyless-unicode-search 2026-11-05 21:44:18 +00:00
Sameh Khamis a75b495cf9 Merge branch 'main' into fix/chat-provider-switch-keeps-context 2026-11-05 13:43:40 -08:00
Yehe LiuandClaude Opus 5.5 ecbe1d99e6 docs: number the wildlife sightings changelog fragment (#1591)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:18:27 -05:00
Yehe LiuandClaude Opus 5.5 14f88b3e08 feat: Wildlife Sightings layer from iNaturalist and Acartia
Recent community wildlife observations, as a new Wildlife group in the
layer panel.

- Server: keyless iNaturalist proxy (v2 observations with only the
  projected fields; undocumented v1 UTFGrid density tiles, decoded on the
  server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget,
  up to four in flight), in-memory caches and upstream-only cooldowns;
  Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with
  comments dropped.
- Layer: grid counts drawn as a soft view-ranked density glow; real sampled
  sightings as taxon badges at their true positions with ambient photo
  thumbnails and labels; obscured records stay privacy cells; a clicked
  sighting stays pinned through camera moves and refreshes.
- Anchored photo card per selection with the single iNaturalist link;
  attribution in the layer row and the Data attribution popover.
- Filters (CONFIRMED, taxa presets) round-trip in share links with token 9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:17:12 -05:00
German Khokhlov 7f8556c691 Merge branch 'main' into exp/cyclone-shared-context
# Conflicts:
#	src/app/constructCatalog.js
2026-11-05 23:01:23 +04:00
Trygve Hemminghytt 47ea4aff1d Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-11-05 19:07:11 +01:00
German Khokhlov 067383fda8 fix(datacenters): drop the redundant seen set and test skipped against exports
The merge test now passes an exported newly tagged feature, so removing
'- set(fresh)' from the skipped report fails it.
2026-11-05 21:42:22 +04:00
German Khokhlov edd6212793 Merge pull request #1481 from IMBrandao/feat/rio-transit-gtfs-rt
feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0)
2026-11-05 21:40:29 +04:00
German Khokhlov b5cef8daf4 Tighten the Rio comments, notes and credits
Name each Rio feed in its credit instead of repeating the licence, drop the
deprecation history from the bus note, list Rio among the feeds with a
route-name table, and shorten the route-table header and test comments.
2026-11-05 21:29:21 +04:00
German Khokhlov 017a3bc99c Merge branch 'main' into feat/rio-transit-gtfs-rt 2026-11-05 21:21:13 +04:00
German Khokhlov 655d93cc92 Keep a newer camera view when a failed Space Missions entry rolls back 2026-11-05 21:21:02 +04:00
Said Turksever e2ca8d9a27 Merge remote-tracking branch 'upstream/main' into pr1585-fix 2026-11-05 17:15:30 +00:00
Said Turksever bc65c274da fix(geocode): keep a leading "the" in keyless name matching
foldPlaceName drops a leading "the", so "The Hague" over upstate New
York accepted Hague, NY as an exact nearby match. Split the script-aware
fold into foldPlaceText and use it for keyless toponyms; foldPlaceName
keeps its behaviour for the packs and street matchers.

Also: changelog fragment credits #1585, folding test pins the Latin
letters NFD does not decompose (ß, ø, ł), and CURRENT-STATE notes that
non-Latin names match only when spelled with their marks.
2026-11-05 17:13:00 +00:00
German Khokhlov 1011dc04fe Merge pull request #1521 from bilawalsidhu/german/hal-721-provider-labels
Name the reporting provider in Contacts and Cockpit counts
2026-11-05 21:11:37 +04:00
German Khokhlov e9e7ed4c1a Merge branch 'main' into german/hal-721-provider-labels 2026-11-05 21:02:35 +04:00
Mert Kaya e4fda9d4e6 fix(geocode): preserve non-Latin names in keyless search 2026-11-05 19:09:00 +03:00
German Khokhlov ed0ad9cf0f Add the changelog entry for #1581 2026-11-05 14:32:39 +04:00
Manjunath ef4b319f97 docs(changelog): credit the review behind the follow-ups 2026-11-05 15:57:09 +05:30
German Khokhlov 70f405cf1b chore(transit): register rioRouteNames.js in the package and format scopes 2026-11-05 13:48:11 +04:00
German Khokhlov a67c7969fd fix(transit): read Rio speeds as the m/s SMTR publishes
Both SMTR feeds now publish Position.speed in m/s. Compared with how far
vehicles moved between reports on 2026-11-05, the published values ran
3.1x (buses) and 3.6x (BRT) below the observed speed, so the km/h
conversion divided real speeds by 3.6. Drop speedUnit and the conversion.
2026-11-05 13:48:11 +04:00
German Khokhlov e5dd7c71ac Merge main into feat/rio-transit-gtfs-rt 2026-11-05 13:35:27 +04:00
Manjunath 852e93d074 fix(camera): keep a deferred recovery check through long holds
A Director hold longer than the settle check's five-minute bound used
to expire the deferred check, so a run that ended on a stars-only shot
after a long hold stayed there until the next move. While a flight or a
playing scene defers the check, its bound no longer counts down; the
flight or scene bounds the deferral itself.

Also correct the trackball note in CURRENT-STATE: Cesium's tilt tests
the screen centre, so any tilt (drag or touch) with the centre on the
stars between 1,000 and 7,500 km now tilts about the planet's edge or
does nothing, wherever it starts. Rotation drags that start on the
planet are unchanged.
2026-11-05 14:45:29 +05:30
Manjunath 29935cc094 fix(camera): run a recovery check a scene deferred once it ends
A check skipped because a flight or a playing Director scene was in
progress used to be dropped, so a run that ended or held on a stars-only
shot stayed there until the next camera move. Keep watching instead:
the settle check retries once the flight or scene ends, within its
existing five-minute bound, and a moveEnd that arrives while it is
deferred keeps the check armed rather than stopping it.

Also test that the free-look limit never raises Cesium's trackball
height and that dispose keeps a value set after install, note in its
JSDoc the enableLook case it cannot tell apart, and document the
trackball change and the retry in CURRENT-STATE and the changelog.
2026-11-05 14:36:10 +05:30
German Khokhlov c64ed40c62 Add the changelog entry for #1578 2026-11-05 12:54:36 +04:00
German Khokhlov 5c04b12164 Roll back a Space Missions entry whose mission index fails to load
The launches layer swallowed every fetch error in its update, so the
manager treated a 429 or timeout on /api/launches as a successful start.
Context then committed the isolating mode with no missions: the other
layers stayed off, unrelated enables were refused, the camera stayed at
the full-globe view and the roster kept reading "loading".

Until the first index loads, the update now reports the failure. The
manager fails the enable closed and both entry routes roll back to the
pre-entry layers. The camera pose captured at entry is restored when the
mode is left before any index loaded, and the roster shows a failed
state instead of loading. A valid empty index still enters, and a
refresh failure after a loaded index keeps its records.
2026-11-05 12:43:49 +04:00
German Khokhlov 33a2876bc0 Fix the translations of the voice geometry, fire filter, airspace and sea mark strings 2026-11-05 12:36:13 +04:00
Manjunath 2f9d13f39d fix(camera): orbit on drags from the stars above 1,000 km
Below Cesium's trackball height (7,500 km by default) a drag that starts
on the stars turns the camera in place, whatever enableLook says, which
was the drag path to a view with no planet in it. Lower that height to
the free-look height with the free-look limit, never raising it, and
restore it on dispose. Above 1,000 km such a drag now orbits the globe,
as it already did above 7,500 km, at a similar rate, and a tilt drag
from the stars there tilts about the planet's edge or does nothing
instead of turning the camera. Drags that start on the planet are
unchanged.
2026-11-05 12:20:26 +05:30
Manjunath 64377db91a fix(camera): leave flights and scenes to their framing
Review follow-ups for the globe-in-view recovery and voice limits.

- Leave a camera flight in progress and a playing Director scene, holds
  included, to their own framing. The recovery is now installed with the
  scene tools, which own the Director, and reads its running state.
- Reuse a voice turn's half-degree proof only from the position and
  direction it was proven at, so a release that moves the camera checks
  the turn again.
- Share one free-globe-camera check between the recovery, the voice
  turn limit and the globe rotation.
- Keep an enableLook setting other code makes after the free-look limit
  is installed, instead of writing back the install-time value.
- Name the nudging test for what it asserts, the 1% floor, and document
  that a view losing its surface without a camera move waits for the
  next one.
2026-11-05 11:59:11 +05:30
Manjunath 1c59105a80 Judge tilts by camera pitch and follow a newer flight at once
- A tilt's angle, limits and "unchanged" check use the camera's own
  pitch. At regional range the pivot pitch is up to about 8 degrees
  shallower, so "tilt to 5 degrees" from 900 km ended at -12.9 degrees.
  The swing now solves for the pivot pitch that gives the camera angle.
- A zoom out during a pivoting tilt stops at the 1,000 km pivot limit
  instead of the orbit's 5,000 km, and a zoom in that reaches the
  clearance is applied before the pivot ends.
- An angle the camera already holds still takes navigation, so it stops
  a running motion and releases the follow camera.
- A motion queued behind a fly-to moves to a newer fly-to as soon as it
  begins, so a stalled lookup no longer holds it past a flight that
  landed. Wait listeners are removed when a wait ends, and a lookup that
  found nothing points straight at the flight that moved.
- A fly-to that stops a running motion names it (stoppedMotion).
- A tilt left or right is refused, and the pitchDeg description says
  steps use direction.
2026-11-05 11:36:29 +05:30
Itallo BrandãoandClaude Opus 5.5 ad48305dac feat(transit): name Rio routes from SMTR's published GTFS
SMTR's realtime feeds carry internal route keys, so a Rio card read
"Route O0731AAN0A" where riders know line SN731. Join both feeds to the
route_short_name in SMTR's static GTFS routes.txt (CC BY 4.0, same
publisher and terms). The keys cannot be parsed: O0014AAA0A is LECD135.
A 2026-11-04 snapshot found all 282 bus and 21 BRT routes in service in
the table; an unknown key still falls through raw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 22:49:54 -03:00
Denis Groshev 459b0e8a71 docs(changelog): add the #1556 fragment 2026-11-04 21:53:06 +01:00
Denis Groshev 219f3e1079 feat(cockpit): enter Cockpit with only the ridden aircraft's layer on
Cockpit entry no longer needs Contacts or both flight feeds: a civilian
aircraft needs Live Flights, a military one Military Flights. Contacts and
every other layer can be turned on or off before or during a session, and
turning off the ridden layer ends it. A Context mode change in flight still
blocks entry.

When the other air feed is off, Live Signals and the Contact card say
FLIGHTS FEED OFF or MILITARY FEED OFF · PARTIAL PICTURE instead of
presenting a partial picture as complete. Voice refuses entry with
subject-layer-off rather than contacts-inactive, and its tool description
says Contacts is not required.

Double-clicking the followed aircraft enters Cockpit through the same gate.
The Cockpit takes the scene's double-click before Cesium's stock handler,
which would otherwise untrack a followed aircraft whose model is a
primitive. The entry also shows under the Intel HUD readout, sharing the
Contacts button's face. A test keeps the classes the Cockpit adds to the
body from matching another element's rule.
2026-11-04 21:53:05 +01:00
German Khokhlov 7d8b80db0a Merge pull request #1561 from bilawalsidhu/german/chat-autocomplete-arabic-marks
Ignore Arabic short vowels and shadda in chat autocomplete
2026-11-04 23:58:19 +04:00
German Khokhlov e84cae6baa Add the changelog entry for #1561 2026-11-04 23:35:46 +04:00
German Khokhlov 06c84f92d3 Ignore Arabic short vowels and shadda in chat autocomplete
Arabic catalog strings carry optional marks ("تتبّع"), but people type
without them, so "تتبع" found no suggestion. Fold the marks out of both
the draft and the candidates before comparing.
2026-11-04 23:35:25 +04:00
manjunath22466 3577d19513 Cover the real provider switch and share chat Settings and stop checks
- Host test switches AI Provider from chat Settings through the real
  provider commands and checks the new mount keeps chat's context, frame,
  Back to chat and focus on the provider choice.
- Drop the open and minimized branches of the carried presentation: the
  provider choice lives only in Settings, so chat is never open then.
- One showChatSettings helper for the gear and the restore, and one
  isStoppable helper for the live-or-error check in chat and the panel close.
2026-11-05 00:17:08 +05:30
Denis Groshev 82f17e694e refactor(cockpit): place the camera from a per-subject eye profile
Split the Cockpit camera update into the aircraft's own front half and a
shared placeCamera that reads the eye's forward and up offsets, pitch and
ground clearance from a profile. The aircraft profile carries the existing
constants, so the aircraft view is unchanged; other subjects can now supply
their own eye.
2026-11-04 17:03:52 +01:00
Itallo BrandãoandClaude Opus 5.5 1b0b4a10dc Merge main into feat/rio-transit-gtfs-rt
Resolves the DATA_SOURCES.md conflict by keeping main's rewritten Transit
and Radio bullets and restating the count for this branch: eighteen feeds
covering seventeen regions (the two Rio feeds share one region).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:53:46 -03:00
German Khokhlov 55e6c0017c Tighten the provider-label comments and docs 2026-11-04 18:15:16 +04:00
manjunath22466 46dde909cc docs(changelog): add #1546 fragment 2026-11-04 19:37:47 +05:30
manjunath22466 53ea76251f fix(voice): keep chat's view across a provider switch and clear stale errors
Switching AI Provider in Settings opened from chat rebuilt the voice
control, so Settings fell back to voice and the dock went idle, widening
the wings. The switch now carries chat's Settings, or chat itself, to the
new provider as it already carries the input mode; the conversation still
starts fresh and focus stays on the provider choice.

After a voice error, opening chat or closing the voice panel left the dock
red on ERROR, because both stopped only an active session. They now also
end a session left in error, which returns the dock to idle.
2026-11-04 19:34:30 +05:30
German Khokhlov 9b7d9ad537 Merge origin/main 2026-11-04 16:35:00 +04:00
German Khokhlov 99831ea19b Keep STALE on Contacts while a poll is in flight
A poll that returned the same stale snapshot dropped STALE from the
Contacts cohort until the next answer, because the in-flight loading flag
outranked stale and the source revision ignored the provider name. A
source that has answered now reads its status without the loading flag;
one that has never answered (AIS first connect) still reads as loading,
so it shows no STALE before any snapshot exists.
2026-11-04 16:34:39 +04:00
Manjunath 4c9b27dc20 Keep a settled flight in reach of a later failed lookup
A new voice fly-to now records the last flight that moved as the one it replaced, landed or not; only a still-flying one passes on its manual-input count. A slow lookup cancelled by a direct flight that landed, followed by a lookup that found nothing, used to drop the queued motion, and a cancelled direct flight in the same place let it fall back to an older landing.
2026-11-04 14:48:07 +05:30
Manjunath b683b8a64b Look past voice lookups that found nothing
A motion queued behind a voice fly-to acts on the last flight that moved the camera. A later lookup that was not found, threw, or was cancelled once a newer voice fly-to had begun moved nothing, so the motion looks past it rather than refusing or running early and cancelling the flight still in the air. It never acts at a flight older than the one it started waiting on. A lookup cancelled while still the latest voice flight (a search, a share link, Cockpit) still refuses.

The top-down no-op now reports the camera's real pitch.
2026-11-04 14:29:37 +05:30
Manjunath bf73a8a1e1 Merge main into the voice tilt branch
No conflicts. The tool payload pin and the Realtime prompt size are unchanged (55,492 with Overpass).
2026-11-04 13:45:45 +05:30
Manjunath 44860df90e Follow a replacement voice flight whatever its predecessor reports
A motion queued behind a voice fly-to now follows a later fly-to in the same reply whether the earlier one arrived, was cancelled or failed. A geocode that loses navigation reports failed, so the motion used to be dropped; and a flight left flying while the next place is looked up could land and run the motion there, taking navigation and cancelling the newer flight. The deadline, abort and stale-reply checks still end the wait.

pitchDeg -90 or -89 from a top-down view is now unchanged rather than a 1° tilt up, and the tilt tests' pitch loops are bounded.
2026-11-04 13:40:43 +05:30
Trygve Hemminghytt 1f0a8b5f16 fix(datacenters): report newly tagged features osmium cannot export
A still-tagged feature without exportable geometry already keeps its old
line. A newly tagged one had nowhere to go and was dropped silently; it is
now listed as skipped with a warning, so a refresh never loses a site or
hides a broken one.
2026-11-03 22:58:09 +01:00
Manjunath d36b28f352 docs(changelog): add the globe-in-view fragment 2026-11-04 01:19:22 +05:30
Manjunath 99520b529c fix(camera): keep the globe in view from orbit
From the whole-globe view the camera could end up facing empty space with
only stars on screen. Cesium's free look (Shift+drag) turns the camera in
place, and from 18,000 km the planet's edge is only about 15 degrees from
nadir, so a few drags turned it out of frame. The rolled pose was then
saved into the share hash, so a reload or shared link opened on stars too.

- Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged.
- Measure how much of the frame shows the planet's surface by casting a
  32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is
  on (the default) only the rays inside its keyhole count, since the mask
  hides or dims the rest of the frame.
- When a camera move ends above 1,000 km with no ray on the surface, turn
  straight down at the same position and heading. Limb glow and a sliver
  up to about one grid cell thick can count as out of view. The end of a
  move comes from Cesium's moveEnd and from a check of its own that
  samples the pose every 250 ms after moveStart and acts once it has held
  for half a second: at some of these poses the camera drifts by a
  rounding error every frame, so Cesium never sees it stop and never
  raises moveEnd. The zoom-out guard from #1522 keeps wheel and pinch
  zoom-outs in view; this catches the other ways a move can end looking
  away, such as a saved or shared view that did, or a
  drag that Cesium turns into an in-place look below its 7,500 km
  trackball height (one that starts on the stars, or a tilt drag with the
  screen centre off the planet). Views with more surface, tracked or
  locked cameras and the 2D and Columbus modes are left alone.
- Voice tilt and in-place turns of a free 3D globe camera above 1,000 km
  stop when about a tenth of the frame still shows the planet (or about as
  much as it showed when it started, two grid cells of sampling noise
  allowed), never below about 1% of the visible frame; a view that already
  shows less only keeps or gains what it has. A turn that cannot go half a
  degree further without crossing that limit is refused with a reason
  instead of reported as done, and a turn it accepts turns at least that
  half degree. A turn that keeps or gains surface is not refused, even
  after the window has been resized, except that near the limit, where the
  planet's edge crosses the sample grid, sampling wobble can occasionally
  refuse one (most often above about 15,000 km and on phone or ultrawide
  frames); nothing then turns away from the planet. A turn that starts
  where the last voice turn left the camera keeps that turn's limit, or a
  higher one if the planet now fills more, so repeating the request does
  not take any more; once anything else has moved or turned the camera, a
  turn is measured from what is on screen. A request made while the camera
  is tracked is judged by the pose voice's release of the follow camera
  will leave, before that release, so a refused turn keeps the tracking.

The frame measure and the look-step limit live in src/globeInView.js,
shared by the viewer and the voice camera verbs.
2026-11-04 01:17:47 +05:30
Manjunath eeb20d24e9 Merge main into the voice tilt branch
Re-pin the Realtime tool payload hash for the merged tree (voice geometry plus pitchDeg). To fit main's 55,500 prompt ceiling with Overpass on, the pitchDeg schema drops its -90..90 range (the runner already clamps to -89..-5, now pinned by a test) and its description is shorter. The Overpass prompt measures 55,492.
2026-11-04 00:06:56 +05:30
Dylan Maniatakes 134597c3cf docs: add S2 age filter changelog for PR 1528 2026-11-03 13:23:40 -05:00
Dylan Maniatakes 36fceb5675 Add adjustable event age filtering to S2 GhostMaps 2026-11-03 13:15:33 -05:00
Manjunath fdecc8c581 Keep the tilt clearance through a zoom in
A zoom in during a pivoting tilt stops at the range that keeps the camera 150 m above the pivot at its current pitch. A zoom that moves the range by less than half a metre (at the clearance, the 80 m floor or the 5,000 km cap) now answers not zoomed instead of ok, and a tilt reports tiltRangeAdjusted rather than orbitRadiusAdjusted.
2026-11-03 22:23:38 +05:30
Manjunath 9d8db97a33 Merge main into the voice tilt branch
Re-pin the Realtime tool payload hash for the merged tree (main's layer changes plus pitchDeg). The Realtime prompt measures 51,960 of 52,000.
2026-11-03 21:58:10 +05:30
Said Turksever 2c936ae131 docs: correct hidden-contact rotation interval 2026-11-03 16:26:45 +00:00
Said Turksever b68cd25e9e Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr870 2026-11-03 16:17:34 +00:00
Said Turksever 66a5c483c2 fix: resolve review findings and refresh PR #870 against main 2026-11-03 16:17:31 +00:00
Manjunath 9e64d517ad Follow up voice tilt review: superseded flights, wait, globe range
- A motion waiting on a voice fly-to follows a later fly-to in the
  same reply that cancels it, within one deadline, instead of
  reporting that its flight did not arrive. A stale reply stops
  following.
- The wait is 15 s, down from 30 s: it holds the tool output, and on
  Gemini the later tools in the turn.
- Beyond 1,000 km of range a tilt happens in place instead of
  swinging thousands of km about the crosshair point.
- The tilt-nudge limits read the pivot pitch the motion starts from,
  including for a voice tilt from a tracked view, whose follow camera
  is released before it moves.
- move_camera's argument checks run before the flight wait, so a
  malformed call is refused at once.
- Tests: t.after cleanup in the fly-then-motion loop, the pitchDeg
  pin asserts the field before removing it, and the handoff-order
  pin follows the extracted argument checks.
2026-11-03 20:05:34 +05:30
Manjunath 2aef67197c Merge main into the voice tilt branch 2026-11-03 19:40:13 +05:30
Manjunath 5c74b860b7 Address review: zoom, drag, clearance and pitchDeg for voice tilt
- A zoom during a pivoting tilt now scales the range the tilt holds,
  as it already did for orbit, instead of being undone on the next
  frame while reporting success. The zoom limits no longer move a view
  the wrong way: a zoom in leaves a view closer than 80 m in place, and
  a zoom out leaves an orbit beyond 5,000 km where it is.
- A grab, touch or wheel on the map while a voice fly-to is in flight
  drops the motion queued behind it. Cesium lands the flight anyway,
  so the orbit or tilt would otherwise take the camera back. Chained
  fly-tos in one reply keep the earliest snapshot.
- A pivot tilt toward the horizon stops lowering the camera at 150 m
  above the pivot and tilts the rest in place, so a close view no
  longer ends inside tiles or a hillside.
- pitchDeg is an absolute angle. A direction that contradicts it is
  refused, judged from the pitch the swing starts at, and the tool
  description says so.
2026-11-03 19:35:02 +05:30
German Khokhlov 490c8d1769 Add the changelog entry for #1521 2026-11-03 16:27:25 +04:00
German Khokhlov 415f23739f Name the reporting provider in Contacts and Cockpit counts
The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol,
AISStream) while the Layers panel and Contacts named the provider the last
snapshot reported, and the vessels layer kept that name out of getStats(),
so Contacts always said AISStream. Each cohort now carries the provider and
whether it answered as a fallback or from a stale snapshot, and both views
show it the way the Layers row does ("adsb.lol · FALLBACK").

Fixes HAL-721.
2026-11-03 16:26:55 +04:00
Manjunath 2ac73519ec Add changelog fragment for voice camera tilt (#1520) 2026-11-03 16:43:01 +05:30
Manjunath 75000cf6a9 Hold a voice orbit, pan or rotate until its fly-to lands
"Fly to Paris and orbit it" reaches the app as two sibling tool calls,
which OpenAI runs at once. With a place that needs a lookup, the orbit
started over the old view and the late flight then replaced it; with a
known place, pan and rotate cancelled the flight already in the air.

Orbit, pan and rotate now wait for a pending voice flight the same way
tilt does: up to 30 s, and not run (saying so) if the flight is
cancelled, finds nothing or times out. Stop and rotate_globe do not
wait. Orbit keeps its own arming for flights the voice runner does not
track.
2026-11-03 16:42:50 +05:30
Manjunath 91a3f787d4 Hold a voice tilt until its fly-to lands
"Fly to London and tilt the camera" reaches the app as two sibling tool
calls, and OpenAI runs them concurrently. The tilt's navigation step
cancelled the flight already in the air, so the camera tilted where it
was; when the place still needed a lookup, the late flight replaced the
tilt and London came out top-down.

The voice runner now tracks its latest flight. A tilt sent while that
flight is still looking up or flying waits for it to land (up to 30 s),
then tilts at the destination. If the flight is cancelled, finds nothing
or times out, the tilt is not run and says so.
2026-11-03 16:42:39 +05:30
Manjunath 69152868e4 Fix voice camera tilt from a top-down view
A voice fly-to frames a city top-down, at pitch -90. The tilt clamp
rejected any step that left the pitch below -89, so the first frame of
"tilt up" stopped the motion and "tilt down" was refused: from the view
voice lands on, no tilt ever moved the camera. A bare "tilt the camera"
also failed because tilt required a direction.

- Clamp tilt only in the direction of travel, so -90 can tilt up.
- Swing the camera about the ground under the crosshair, so the place
  stays centred and the heading is kept. A tracked view still tilts in
  place, and a pivot tilt yields when tracking takes the camera.
- Add an optional move_camera pitchDeg for "tilt 45 degrees"; a tilt
  with neither angle nor direction settles at -45 (oblique view).
- Re-derive the voice tool schema pins for the new argument and wording,
  and keep the Realtime prompt under its 52,000-character budget.
2026-11-03 16:42:18 +05:30
German Khokhlov 92666fdbaf fix(cyclones): preserve movement in selected voice context 2026-11-03 15:10:20 +04:00
German Khokhlov c68f167acb fix(transit): count distinct coverage regions 2026-11-03 15:07:00 +04:00
German Khokhlov 04d626a4b1 fix(datacenters): preserve missing geometry and compare ring topology 2026-11-03 15:02:42 +04:00
Itallo BrandãoandClaude Opus 5.5 53d30c9e80 docs(changelog): add fragment for #1481
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:30:47 -03:00
Itallo BrandãoandClaude Opus 5.5 a4c7b20ca8 feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0)
Register Rio's two keyless GTFS-Realtime VehiclePositions feeds from the
city transport secretariat (SMTR): municipal buses (SPPO) and the
Mobi-Rio BRT, the Transit layer's first Latin American region.

Both feeds publish Position.speed in km/h instead of m/s. A new optional
registry field, speedUnit: 'kmh', makes buildTransitSnapshot convert it
once, so every speedMps consumer stays correct and other feeds are
untouched.

Document the source in DATA_SOURCES.md and update the region lists in
README.md and docs/CURRENT-STATE.md.

Refs #1480

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:11:10 -03:00
Kevin Rajan deb02f635e Merge current upstream main into explicit cyclone context PR
Preserve reviewed selection event-ordering semantics and real-store tests.
Register the new app-layer wrapper in both package boundary sections.
2026-10-22 17:39:51 -05:00
Said Turksever c6decfced4 Merge latest main before publishing PR #847 fixes 2026-10-20 14:42:17 +01:00
Said Turksever b358b5dba1 fix(cyclones): distinguish deliberate clears from feed eviction 2026-10-20 14:17:39 +01:00
Said Turksever 1330605c18 Merge main into PR #847 preparation candidate 2026-10-20 14:14:38 +01:00
Said Turksever 9a5cafca27 Merge upstream/main into exp/cyclone-shared-context 2026-10-19 14:23:54 +01:00
Trygve Hemminghytt 4318f51de4 docs: move the changelog entry to changelog.d 2026-10-18 20:03:23 +02:00
Trygve Hemminghytt 52603f0afe Merge remote-tracking branch 'origin/main' into HEAD 2026-10-18 20:02:39 +02:00
Kevin Rajan 0e2ae76f0a test(cyclones): assert source-owned context label 2026-10-17 03:36:09 -05:00
Kevin Rajan 3a7c7b7c6a fix(cyclones): keep selected names source-owned for i18n 2026-10-17 03:36:07 -05:00
Kevin Rajan abaaf0c254 fix(cyclones): reconcile explicit shared selection with current main
Carry reviewer-verified stale A-to-B record eviction, explicit selection
and refresh semantics over latest i18n/cyclone source ownership.
2026-10-17 03:31:28 -05:00
Kevin Rajan a803be1af2 fix(cyclones): evict prior shared selection on storm switch
Regression: explicit storm A→B→A keeps one active record.
Reviewer finding: quanticsoul4772 (#847).
2026-10-16 23:01:44 -05:00
Trygve 7e765a3f93 Merge branch 'main' into feat/datacenters-norway-refresh 2026-10-16 22:57:02 +02:00
Trygve Hemminghytt 3c26636ab4 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-16 22:23:03 +02:00
Trygve Hemminghytt 5b5d942d95 fix(data): make the Norway datacenter script portable and re-runnable
Read and write files as UTF-8 with LF line endings so the script and
--verify work on Windows, where the default codec is cp1252 (thanks to
quanticsoul4772 for the report). Geofabrik keeps only its newest dated
extract, so --extract now defaults to the current one and accepts a dated
name for --verify while it is listed; the dataset README says so. Downloads
time out instead of hanging.
2026-10-16 22:23:02 +02:00
Trygve Hemminghytt 2e0d855cf8 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 23:04:04 +02:00
Trygve Hemminghytt 4ce28c83c0 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 22:35:13 +02:00
Trygve Hemminghytt 239ba14005 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 21:06:43 +02:00
Trygve Hemminghytt cd83fc62fe Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 15:08:35 +02:00
Trygve Hemminghytt 6292b13d75 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 14:14:26 +02:00
Trygve Hemminghytt ed1982ab96 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-14 16:42:18 +02:00
Trygve Hemminghytt a9f16fecd3 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-13 18:33:51 +02:00
Trygve Hemminghytt bba786d215 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-12 18:38:24 +02:00
trygvehhh 295d089377 Merge branch 'main' into feat/datacenters-norway-refresh 2026-10-11 13:24:12 +02:00
Trygve Hemminghytt 6cf641967b Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-08 21:28:09 +02:00
Trygve Hemminghytt 7ee51c093a Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-08 00:39:37 +02:00
Trygve Hemminghytt 621b87c52c Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-07 21:41:41 +02:00
Trygve Hemminghytt a953c9a545 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-07 15:44:35 +02:00
Trygve HemminghyttandClaude Opus 5.5 aa745bcee5 data: refresh Norway datacenters from Geofabrik extract
Add a maintainer script that merges a pinned, MD5-verified Geofabrik Norway
extract (osmium tags-filter telecom/building=data_center) into the bundled
datacenter layer. Only features inside the Geofabrik Norway boundary are
touched; unchanged features keep their original line.

norway-261005 (OSM 2026-10-05) adds Troll Housing (w1551029155) and updates
tags on four existing features. Provenance is recorded in the dataset README
and the bundled count moves from 4,351 to 4,352.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 04:16:50 +02:00
Wilmer AlzateandClaude Opus 5.5 81f49406b8 perf(flights): skip dead-reckoning contacts that stay behind the horizon
The fleet tick dead-reckoned, floored and distance-tested every contact on
every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed
carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all
of that work produced a hidden billboard.

A contact that is already hidden and still behind the horizon at its last
position now re-reckons only on its turn in a staggered one-in-12-ticks
rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that
position puts in view, and any contact with a fleet model, takes the full
pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive
proximity queries (getNearby includeHidden, getAllPositions) read positions
at most about a second old, well inside the 30 s poll's uncertainty.

Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over
Colombia at 4 km, same view before and after:
  fleet tick   7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms
  frame        20.6 -> 14.0 ms, 42.9 -> 57.8 FPS
Teleporting the camera to Madrid showed 975 contacts within 100 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:31:59 -05:00
Kevin Rajan 28f3243090 feat(cyclones): publish explicit selection to shared context 2026-09-29 00:20:16 -05:00
1262 changed files with 48579 additions and 118364 deletions

No files matched your search

-4
View File
@@ -59,10 +59,6 @@ jobs:
- name: Build production bundle
run: npm run build
- name: Main chunk size budget
if: matrix.node == '24.22.2'
run: npm run check:main-chunk
street-level-browser:
# Hermetic: every Mapillary request is answered from fixtures, so the
# dummy token below never leaves the page.
+8 -3
View File
@@ -33,7 +33,6 @@ How to read this:
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
@@ -42,6 +41,8 @@ How to read this:
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" |
| **Acartia** (`acartia.io/api/v1/sightings/current`) | Wildlife Sightings layer: Salish Sea marine mammal reports with validated species, observation time, reporting provider and trust flag; WHALES includes only the frozen cetacean list | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), each provider credited per sighting. Keyless via `/api/acartia/sightings` (10-min cache; stale ≤ 24 h). Positions rounded to 0.01° on the server; free-text comments excluded. Marine-conservation use; Be Whale Wise | "Wildlife sightings: Acartia (CC BY 4.0; each provider credited per sighting) · Be Whale Wise" |
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
@@ -99,6 +100,7 @@ How to read this:
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
@@ -186,7 +188,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_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.
- **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 eighteen registered feeds, covering seventeen 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, YRT and Rio’s SMTR 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 country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/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; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist 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.
@@ -195,6 +197,9 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover.
- **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals.
- **iNaturalist grid (cell schema risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is documented in the v1 and v2 API docs as “Grid Tiles UTFGrid” (`/grid/{z}/{x}/{y}.grid.json`), but its per-cell records are not specified; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`.
- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. `/sightings/current` answers without a key today, but Acartia's README asks API users to create an account and generate an API key on the [Acartia dashboard](https://acartia.io/) and to follow its [community guidelines](https://acartia.io/register). Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census.
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
@@ -212,7 +217,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| Dataset | Folder | License | Commercial use? | Attribution |
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
+6 -7
View File
@@ -340,8 +340,6 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
Open **Visual Presets → Globe Engine** to switch between Cesium and experimental Navara. The globe switches in place at the current view and remembers your choice. A link with `?engine=cesium` or `?engine=navara` overrides the saved choice. A fresh visit starts with Cesium.
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
---
@@ -428,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
## 🛰️ What's on the Globe
Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
@@ -446,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
| 📷 **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 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 | 🟢 |
| 🚌 **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, Rio de Janeiro (buses and BRT), 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) | 🟢 |
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
@@ -454,6 +452,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
| 🚀 **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 | 🟢 |
| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 |
| 🦉 **Wildlife Sightings** | Wildlife observed within 48 h (whales 7 d): density glow, taxon badges and anchored photo cards with creator/licence credit. Salish Sea marine mammals via Acartia; sensitive locations shown as 0.2° cells. Not a census | iNaturalist, Acartia | 🟢 |
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
@@ -474,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev
_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712).
![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif)
@@ -486,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **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.
**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 Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **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. 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.
**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 when its token is set; otherwise KartaView is. 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.
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`.
+1 -1
View File
@@ -81,7 +81,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **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. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. 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.
- **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 map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. 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.
+2 -21
View File
@@ -2,8 +2,8 @@
The project depends on third-party npm packages whose own licenses apply. This
file records the packages added for the browser-local SDR feature and the
Street Level viewer, and the CesiumJS code ported into `src/geo/`; the complete
resolved dependency inventory remains in `package-lock.json`.
Street Level viewer; the complete resolved dependency inventory remains in
`package-lock.json`.
## Web RTL-SDR
@@ -45,22 +45,3 @@ resolved dependency inventory remains in `package-lock.json`.
- Source: <https://github.com/mrdoob/three.js>
- License: MIT
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
## CesiumJS (portions ported to src/geo)
- Package: `cesium` 1.138.0 (`@cesium/engine` 22.3.0); the files in `src/geo/`
are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`,
`Transforms.js`) and say so in their first
line. The changes: the classes' static functions became module functions,
vectors, geodetic positions and quaternions became plain-data instances of
module-private constructors (own enumerable fields only, no methods),
matrices plain arrays, and the ellipsoid a frozen object; the debug
argument checks were left out, as in Cesium's release build; only the subset
gev uses was kept.
- Author: Copyright 2011-2024 CesiumJS Contributors
- Source: <https://github.com/CesiumGS/cesium>
- License: Apache License 2.0
- License text: `node_modules/@cesium/engine/LICENSE.md` (shipped with the
dependency) and <https://www.apache.org/licenses/LICENSE-2.0>
-58
View File
@@ -1,58 +0,0 @@
// Build guard: the Cesium-mode page must not load three or Navara. The
// initial chunks are every entry chunk and what it imports statically;
// navaraMode.js reaches Navara only through import(), so three and
// @navaramap/* belong to lazy chunks. This checks the bundle's module ids
// (what Rollup put in each chunk), and fails the build otherwise.
/**
* Module ids that only the Navara engine may load, when no package list is
* given (the browser build passes the full dependency closure,
* navaraDependencies.js).
*/
export const ENGINE_ONLY_MODULES =
/[\\/]node_modules[\\/](?:three|@navaramap[\\/][^\\/]+|postprocessing)[\\/]/;
/**
* Engine-only modules in initial chunks of a Rollup bundle, sorted by chunk.
* @param {Record<string, { type: string, isEntry?: boolean, imports?: string[], moduleIds?: string[] }>} bundle
* @returns {{ chunk: string, module: string }[]}
*/
export function initialChunkViolations(bundle, pattern = ENGINE_ONLY_MODULES) {
const initial = new Set();
const visit = (fileName) => {
const chunk = bundle[fileName];
if (!chunk || chunk.type !== 'chunk' || initial.has(fileName)) return;
initial.add(fileName);
for (const imported of chunk.imports ?? []) visit(imported);
};
for (const [fileName, chunk] of Object.entries(bundle))
if (chunk.type === 'chunk' && chunk.isEntry) visit(fileName);
const violations = [];
for (const fileName of [...initial].sort())
for (const module of bundle[fileName].moduleIds ?? [])
if (pattern.test(module)) violations.push({ chunk: fileName, module });
return violations;
}
/** The Rollup plugin: fails generateBundle when an initial chunk holds three or Navara. */
export function initialChunkGuard({
log = console.log,
pattern = ENGINE_ONLY_MODULES,
} = {}) {
return {
name: 'gev-initial-chunk-guard',
generateBundle(_options, bundle) {
const violations = initialChunkViolations(bundle, pattern);
if (violations.length) {
this.error(
'Navara-only modules in the Cesium-mode initial chunks:\n' +
violations
.map(({ chunk, module }) => ` ${chunk}: ${module}`)
.join('\n'),
);
return;
}
log('[initial-chunks] no Navara-only modules in the initial chunks');
},
};
}
-65
View File
@@ -1,65 +0,0 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Where @navaramap/three keeps its prebuilt worker assets, or null. */
function defaultAssetsDir() {
try {
const entry = fileURLToPath(import.meta.resolve('@navaramap/three'));
return path.join(path.dirname(entry), 'assets');
} catch {
return null;
}
}
/**
* Navara's prebuilt workers (@navaramap/three/dist/assets) load their wasm by
* file name next to themselves (`new URL('x-hash.wasm', self.location)`), a
* reference the bundler cannot see. When a build includes Navara (the
* ?engine=navara chunk), copy every such wasm the bundle lacks into
* `assets/` under its own name. A build without Navara gets none.
* @param {{ assetsDir?: string | null }} [options]
*/
export function navaraWorkerAssets({ assetsDir } = {}) {
return {
name: 'navara-worker-assets',
generateBundle(_options, bundle) {
const usesNavara = Object.values(bundle).some(
(file) => file.type === 'chunk' && file.code.includes('navara_wasm'),
);
if (!usesNavara) return;
const dir = assetsDir === undefined ? defaultAssetsDir() : assetsDir;
if (!dir || !existsSync(dir)) return;
for (const name of readdirSync(dir).sort()) {
if (!name.endsWith('.wasm')) continue;
const fileName = `assets/${name}`;
if (bundle[fileName]) continue;
this.emitFile({
type: 'asset',
fileName,
source: readFileSync(path.join(dir, name)),
});
}
},
};
}
const NAVARA_MODULE = /\/src\/globe\/navara\//;
/**
* Rollup chunkFileNames: the lazy chunk holding the Navara engine
* (src/globe/navara, with the Navara packages it pulls in) is named
* `navara-engine-[hash].js`; every other chunk keeps Vite's default name.
* Naming only (not manualChunks, which would pull modules shared with the
* Cesium entry into the Navara chunk), so the chunking is unchanged.
* @param {{ isEntry?: boolean, moduleIds?: string[] }} chunk
* @returns {string}
*/
export function navaraChunkFileNames(chunk) {
const navara =
!chunk.isEntry &&
(chunk.moduleIds ?? []).some((id) =>
NAVARA_MODULE.test(id.replaceAll('\\', '/')),
);
return navara ? 'assets/navara-engine-[hash].js' : 'assets/[name]-[hash].js';
}
-73
View File
@@ -1,73 +0,0 @@
// The packages only the Navara engine brings: the dependency closure of
// @navaramap/three and @navaramap/three-default-plugin (dependencies and
// peer dependencies, read from node_modules), without the packages the
// app's other bundled dependencies reach too (Cesium is external), plus
// three and postprocessing, which
// the Cesium-mode page never loads statically either. The initial-chunk
// guard (initialChunkGuard.js) fails the build when one of them is in an
// initial chunk.
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
export const NAVARA_ROOTS = Object.freeze([
'@navaramap/three',
'@navaramap/three-default-plugin',
]);
const ALWAYS_ENGINE_ONLY = Object.freeze(['three', 'postprocessing']);
// Not bundled: vite-plugin-cesium serves Cesium as a script, so what only
// Cesium reaches (draco3d, …) is not in the app's bundled graph.
const EXTERNAL = Object.freeze(['cesium']);
function readManifest(root, name) {
const file = path.join(root, 'node_modules', name, 'package.json');
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
}
/** Every package reachable from `names` through installed manifests. */
export function dependencyClosure(root, names) {
const seen = new Set();
const queue = [...names];
while (queue.length) {
const name = queue.pop();
if (seen.has(name)) continue;
const manifest = readManifest(root, name);
if (!manifest) continue;
seen.add(name);
for (const next of Object.keys({
...manifest.dependencies,
...manifest.peerDependencies,
}))
queue.push(next);
}
return seen;
}
/** The engine-only package names, sorted. */
export function navaraOnlyPackages(root) {
const app = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
const navara = dependencyClosure(root, NAVARA_ROOTS);
const appRoots = Object.keys(app.dependencies ?? {}).filter(
(name) =>
!navara.has(name) &&
!ALWAYS_ENGINE_ONLY.includes(name) &&
!EXTERNAL.includes(name),
);
const shared = dependencyClosure(root, appRoots);
return [
...new Set([
...[...navara].filter((name) => !shared.has(name)),
...ALWAYS_ENGINE_ONLY,
]),
].sort();
}
/** A module-id pattern matching node_modules/<package>/ for the names. */
export function packagePattern(names) {
const escaped = names.map((name) =>
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('/', '[\\\\/]'),
);
return new RegExp(
`[\\\\/]node_modules[\\\\/](?:${escaped.join('|')})[\\\\/]`,
);
}
-6
View File
@@ -26,12 +26,6 @@ export function panelBuildConfig(config) {
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
// The panel inlines every dynamic import into its one script, the
// experimental Navara mode included, so the guard that keeps Navara
// out of the app's initial chunks does not apply here.
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
),
output: { inlineDynamicImports: true },
},
},
+2 -36
View File
@@ -5,9 +5,6 @@ import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js';
import { navaraChunkFileNames, navaraWorkerAssets } from './navaraAssets.js';
import { initialChunkGuard } from './initialChunkGuard.js';
import { navaraOnlyPackages, packagePattern } from './navaraDependencies.js';
/**
* The repository root (this file lives at `<root>/build/vite.js`), as a
@@ -186,9 +183,7 @@ export function createBrowserViteConfig({
apiResponseHeadersPlugin(),
// Next, so no provider route answers a Host the server does not allow.
hostCheckPlugin(),
// Keep the SDK asset serving/copying and external imports, but let the
// scene load the script and stylesheet only when Cesium is requested.
{ ...cesium(), transformIndexHtml: undefined },
cesium(),
// Application views are Vue single-file components.
vue(VUE_PLUGIN_OPTIONS),
...plugins,
@@ -211,18 +206,6 @@ export function createBrowserViteConfig({
// Loaded on demand when the first street-level image opens.
'mapillary-js',
],
// Navara's wasm-bindgen glue loads new URL('x_bg.wasm', import.meta.url)
// and its workers by URL; both must resolve next to the package's own
// files, not inside .vite/deps. Dev server only: the experimental
// Navara engine is a dynamic import outside the Cesium-mode bundle.
exclude: [
'@navaramap/three',
'@navaramap/three-default-plugin',
'@navaramap/engine',
'@navaramap/engine-api',
'@navaramap/engine-worker',
'@navaramap/engine-font-worker',
],
},
server: {
host: host || 'localhost',
@@ -275,23 +258,6 @@ export function createBrowserViteConfig({
mapillaryToken ?? '',
),
},
build: {
chunkSizeWarningLimit: 1500,
// Build only: the wasm Navara's workers load by name (the Navara
// engine is a separate chunk loaded on ?engine=navara).
// The lazy chunk with the Navara engine is named navara-engine.
rollupOptions: {
// initialChunkGuard: no three/Navara module in the initial chunks.
plugins: [
navaraWorkerAssets(),
initialChunkGuard({
pattern: packagePattern(
navaraOnlyPackages(fileURLToPath(new URL('..', import.meta.url))),
),
}),
],
output: { chunkFileNames: navaraChunkFileNames },
},
},
build: { chunkSizeWarningLimit: 1500 },
};
}
+5
View File
@@ -0,0 +1,5 @@
- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT
from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin
American region. Rio's internal route keys are joined to SMTR's published
GTFS `routes.txt`, so a card reads "Route SN731" rather than
"Route O0731AAN0A" (IMBrandao, #1481).
+6
View File
@@ -0,0 +1,6 @@
- Voice "tilt the camera" works from the top-down view a fly-to lands on: the
camera swings about the place under the crosshair, keeps its heading, and can
tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt,
orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now
waits for the flight to land, and is dropped if you move the map meanwhile
(Manjunath, @manjunath22466, #1520).
+4
View File
@@ -0,0 +1,4 @@
- Cockpit Contact counts and the Contacts list name the provider that
answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from
the adsb.lol fallback no longer read OpenSky, and vessels no longer always
read AISStream (German, @gekh, #1521).
@@ -0,0 +1,4 @@
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
Preserve undated features, save the selected window and expire old events
while the map stays open. Thanks @dylanmaniatakes (#1528).
+11
View File
@@ -0,0 +1,11 @@
- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned
the camera in place, so a few drags from the whole-globe view left only
stars, and the pose stayed in the share link. Free look now works only below
1,000 km, and above it a drag that starts on the stars orbits the globe
instead of turning the camera; a camera move that ends above it with no
planet surface in view (inside the scope keyhole when the scope is on) turns
straight down; and voice tilt and turns from orbit stop while the planet is
still on screen and say so instead of reporting a turn they could not make.
Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that
closed the drag path and kept authored scenes framed (Manjunath,
@manjunath22466, #1532).
+5
View File
@@ -0,0 +1,5 @@
- Keep typed chat's view when switching AI Provider: Settings opened from
chat stay chat's Settings for the new provider instead of falling back to
voice and widening the dock. Opening chat or closing the
voice panel now also clears a voice error left on the dock (Manjunath,
@manjunath22466, #1546).
-10
View File
@@ -1,10 +0,0 @@
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
Cesium, switchable live under Display → Globe Engine or with
`?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD
(`?engine=navara-hd`) draws at the screen's full resolution and needs about
1 GiB more on a Retina screen. Layers keep their data across a switch and
draw on both engines; Cesium loads only when a Cesium globe or a Cesium-only
feature needs it (German, @gekh, #1555).
- Keep the cockpit camera steady near the ground: it no longer jumps up and
down when an aircraft's reported altitude sits below the globe's terrain,
on either engine (German, @gekh, #1555).
@@ -0,0 +1,6 @@
- Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer
required, and ending Contacts mid-session keeps the view and the layer it
rides; turning off the aircraft's own feed ends the view, a missing other
feed reads `… FEED OFF · PARTIAL PICTURE`, and double-clicking the followed
aircraft or the entry beside its readout enters it (Denis Groshev, @ZloyS,
#1556).
@@ -0,0 +1,3 @@
- Chat autocomplete finds Arabic suggestions typed without short vowels or
shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر»
(German, @gekh, #1561).
@@ -0,0 +1,4 @@
- Space Missions no longer locks the workspace when its mission index fails to
load (429, timeout or bad payload): entry rolls back to the previous layers
and camera view and a toast says the index could not load (German, @gekh,
#1578).
@@ -0,0 +1,4 @@
- Fix the translations of the voice area cards, the fire confidence filter,
the drone airspace layer and Sea Marks: one word for "landmark" per
language, the airspace layer names the airspace again, and Traditional
Chinese follows Simplified Chinese again (German, @gekh, #1581).
@@ -0,0 +1,3 @@
- Keep keyless place search on a nearby exact match for names written in
non-Latin scripts instead of discarding the match and retrying without its
proximity ranking (mertkayacs, #1585).
+12
View File
@@ -0,0 +1,12 @@
- Add Wildlife Sightings in the Wildlife layer group, off by default, using
keyless iNaturalist observations within 48 h (whales 7 d) and Acartia Salish
Sea marine mammals. CONFIRMED and taxa filters share via token `9`.
A noninteractive density glow accompanies real-sighting taxon badges,
decluttered species labels and credited ambient photos. Selection stays
pinned across camera and sample changes and opens an anchored photo card
with a contained image, creator/licence and one iNaturalist source link.
Obscured and Acartia sightings use text-only cards; private records and
Acartia comments are excluded. Attribution appears in the layer row and
Data attribution popover. Cached progressive tiles, bounded image/billboard
work and a paced queue preserve iNaturalist's 60/minute and 8,000/day limits.
All 11 locales supported. (lyehe, #1591)
@@ -0,0 +1,4 @@
- Without a Mapillary token, Street Level starts on the first keyless source
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
the globe. A share link that turns a source off is respected
(asturksever, #1601).
@@ -0,0 +1,4 @@
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
Lines draws each road with flow in green, amber or red, and Dots shows or
hides the moving dots. A host can choose which road modes it offers; without
OpenStreetMap the layer never draws simulated roads (#1609).
@@ -0,0 +1,4 @@
- Typed chat on OpenAI answers again: OpenAI refused every message because
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
not accept. Chat leaves those patterns out; area ids are still checked
before the tool runs (samehkhamis, #1616).
+3
View File
@@ -0,0 +1,3 @@
- Turning CCTV on opens the camera nearest you instead of the first camera in
the catalog, which showed a Sydney camera in London. A share link opens on
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
@@ -0,0 +1,3 @@
- Let the voice assistant read the storm you select in Cyclone advisories:
its official advisory, position, intensity and movement. Clearing the
selection and the storm leaving the feed are reported apart (kvnloo, #847).
+4
View File
@@ -0,0 +1,4 @@
- Reduce dead-reckoning work for flights already hidden behind the horizon.
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
second) while visible contacts and contacts with fleet models retain the full
update path (Wilmer Alzate, #870).
@@ -0,0 +1,2 @@
- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing
Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953).
+4 -16
View File
@@ -32,8 +32,6 @@ export inventory. Use declared exports rather than reaching into internal files.
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades |
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
| `src/geo/` | Engine-neutral WGS84 maths, ported from Cesium, never imports Cesium |
| `src/globe/` | Engine-neutral globe contract and its test support; engines only in `globe/cesium`, `globe/navara` |
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
@@ -55,25 +53,15 @@ See [application construction](APPLICATION.md) and the
2. `scripts/check-package-boundaries.mjs` builds every declared export without
app Vite configuration or environment files. `scripts/package-boundaries.json`
assigns each export exactly once and lists its owned modules and external
dependencies; a module entry ending in `/` (such as `src/geo/`) owns every
file under that directory. Unused imports still count. Node exports have only a `node`
dependencies. Unused imports still count. Node exports have only a `node`
condition; browser groups cannot use build-only dependency exceptions.
Portable source graphs cannot reach application/rendering, Node, Cesium or
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
flight/military/vessel record and ingestion exports, action schemas, the session
interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
engine-neutral geometry ported from Cesium). The browser-global rule reserves
platform names such as `document` and `window` in these modules; it is an
architectural check, not a JavaScript sandbox. The globe contract
(`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`,
`@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through
helpers, nor import an engine implementation; the `src/globe/cesium/**` graph
reaches no Navara/three package or `src/globe/navara/**` module, and the
`src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module,
through helpers too. Outside its own directory, `src/globe/navara/**` is
reached only through a literal `import()` (the app's `?engine=navara` mode), so
Navara stays out of the Cesium-mode bundle. Common voice controls cannot depend on a
interface, lifecycle and feed state. The browser-global rule reserves platform
names such as `document` and `window` in these modules; it is an architectural
check, not a JavaScript sandbox. Common voice controls cannot depend on a
Realtime protocol implementation. Negative fixtures cover indirect helpers,
self-package imports, symlinks and unreachable files.
+245 -130
View File
@@ -65,6 +65,59 @@ settling. Oblique views below 300 km are left alone.
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
the real browser controller at desktop and phone widths with keyless fixtures.
Cesium's free look (Shift+drag, which turns the camera in place) works only
below 1,000 km. Above that height a few drags could turn the whole planet out of
frame and leave only stars, so Shift+drag does nothing there. Cesium also turns
the camera in place for a drag that starts on the stars below its trackball
height (7,500 km by default); that height is lowered to 1,000 km, so such a drag
orbits the globe above it, as it does higher up, and a tilt (drag or touch)
while the screen centre is on the stars there tilts about the planet's edge or
does nothing, wherever it starts. Rotation drags that start on the planet, and
zoom, are unchanged. How much of the frame shows the planet's surface is
measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope
mask is on (the default) only the rays inside its keyhole count, since the mask
hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above
1,000 km stop when about a tenth of the frame still shows the surface (or about
as much as it showed when it started), never less than about 1% of the visible
frame, and a view that already shows less only keeps or gains what it has. A
turn that cannot go half a degree further without crossing that limit is refused
with a reason instead of reported as done, and a turn it accepts turns at least
that half degree. A voice tilt to an angle (or a bare tilt, to -45°) that the
limit stops short of is aimed at the angle where it stops, and its result names
that angle (`pitchDeg`) with a `note`. A turn that keeps or gains surface is not refused, even after
the window has been resized, except that near the limit, where the planet's edge
crosses the sample grid, sampling wobble can occasionally refuse one (most often
above about 15,000 km and on phone or ultrawide frames); nothing then turns away
from the planet. A turn that starts where the last voice turn left the camera
keeps that turn's limit, or a higher one if the planet now fills more, so
repeating the request does not take any more; once anything else has moved or
turned the camera, a turn is measured from what is on screen. When a camera move
ends above 1,000 km with no ray on the surface, the camera turns straight down
at the same position and heading. The end of a move is taken from Cesium's
move-end event and from a check of its own that samples the pose every 250 ms
after a move starts and acts once it has held for half a second, because at some
of these poses the camera drifts by a rounding error every frame and Cesium
never sees it stop. That check keeps watching until move-end arrives, recovering
once after each move that comes to rest, and gives up five minutes after the
camera last moved. Limb glow, and a surface sliver up to about one grid cell
thick (some 50 px on a 900 px tall frame), can count as out of view. The
zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the
other ways a move can end looking away, such as a saved or shared view that did.
Views with more surface than that, such as the oblique orbital Director shots,
are left as they are, and so are tracked or locked cameras, a camera flight in
progress, a Director scene while it plays (holds included), a pointer held
down on the globe (a drag held still; with several fingers down, until the
last is released) and the 2D and Columbus modes; a check a flight, scene or
held pointer defers runs once it ends. A release outside the window that the
page never sees counts as given once the window loses focus, the page is
hidden, the canvas loses the pointer's capture, or the pointer moves with no
button down. A Director
shot loaded or replayed outside a run is not a playing scene. A view that loses
its surface without a camera move (a resize, or turning the scope on) is left as
it is until the next move. A voice turn requested while the camera is tracked is
judged by the pose voice's release of the follow camera will leave, before that
release, so a refused turn keeps the tracking.
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
@@ -81,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
The browser continues to split larger workloads into sequential 64-point requests.
Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
@@ -98,90 +151,104 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
implementations.
## Navara globe engine — November 5, 2026
## Wildlife Sightings — iNaturalist and Acartia
Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs
next to Cesium. It is picked under Display → Globe Engine or with
`?engine=navara`; Cesium stays the default. Switching is live: the page keeps
one application, and only the globe-bound parts move to the next engine.
**Wildlife Sightings** (`wildlife-sightings`, share-link token `9`) is off by
default in the Wildlife group between Events and Weather. CONFIRMED and six
taxa presets round-trip in share links. Voice is off for this layer for now;
it is outside analyst queries and DETECT. Community observations are not a
census; private records are excluded.
- **Two Navara choices** (`src/app/engineChoice.js`). **Navara** draws at CSS
pixels without MSAA, as Cesium does; SMAA still smooths edges. **Navara HD**
(`?engine=navara-hd`) draws at the device pixel ratio with MSAA 4. Both are
the `navara` engine to the layers; a pick between them builds the other
variant cold and releases the first, so only one Navara holds memory.
Measured in headless Chrome (Dubai, 1280×800): Navara 1.6 GiB at DPR 1 and
1.8 GiB at DPR 2, Navara HD 1.8 and 2.7 GiB, Cesium 0.93 and 1.08 GiB
(Navara used 1.95 and 3.0 GiB before).
- **Memory.** The canvas context has no antialias (Navara draws into its own
targets and copies the result), the cloud shadow maps use the low preset's
256² and 2 cascades, and the tile cache budget is the screen-sized targets
plus 768 MiB instead of Navara's default of up to 2 GiB, which a long session
filled. A released Navara keeps no JS: catalog closures capture the
operations, not the scene; a released globe aborts with a reason made at
module load (a fresh one keeps the stack, and with it the scene); and
`Pass.fullscreenGeometry` from postprocessing is disposed after the view,
which otherwise pins the canvas through a three listener.
`/api/inaturalist/observations` requests verifiable wildlife in view within
48 h (whales 7 d). Recency uses observation time: the `d1` date filter and a
strict rolling cutoff. Date-only rows qualify only when their day starts
within the window. Close views use a cached random sample of at most 200 rows;
global samples use newest observations first. Observation caches last five
minutes (global ten minutes), with stale fallback for at most one hour.
- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and
`src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands
in for tests, and `src/globe/testing/contractSuite.mjs` runs the same checks
on every engine. `movers` and `lines` are optional capabilities: only
Navara has them, and the suite skips their checks elsewhere.
- **The session owns the state.** Catalog, layer models, sources, selection,
camera mode and URL state outlive a switch. A layer that is a session model
gets `attachRenderer(engine)` / `detachRenderer()` instead of being rebuilt,
so its data is not fetched again. The outgoing globe is parked
(`src/standalone/rendererCache.js`), so switching back within three minutes
is warm (60-90 ms). After that the parked globe is disposed and its WebGL
context lost, which frees about 1.1 GiB for a parked Navara; switching back
then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA
(`scripts/qa-strict-parked-release.mjs`).
- **Renderers per engine.** Each layer keeps its model and display policy in
engine-neutral files and draws through `render/cesium*.js` or
`render/navara*.js`. Cesium renderers are listed in
`src/app/layers/renderers.js` (`CESIUM_LAYER_RENDERERS`), Navara ones are
loaded by `registerNavaraLayerRenderers` in `src/app/navaraMode.js`. Navara
ground effects (heat, pulses, trails, pads, drapes) are declared with
`defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
their insert order is pinned by `navaraGroundOrder.test.mjs`.
- **Cesium only on demand.** The production build loads `/cesium/Cesium.js`
when a Cesium globe starts or a Navara user turns on a feature that still
uses Cesium's maths or entity model (traffic, Street Level, the Bhote Koshi
event and locator, geo import; `src/app/loadCesium.js`). App code does its maths with `src/geo`, a Cesium-free
port of the vector, ellipsoid, transform and time functions that
`bitExact.test.mjs` compares with Cesium bit for bit.
**Invariant: no module reads Cesium while it loads**
(`scripts/check-cesium-load-time.mjs`, run by `npm test`).
- **Boundaries.** Cesium is imported only from `render/cesium*.js` and
`src/globe/cesium/*`; three, postprocessing and Navara only from `navara*`
modules, which load through `import()`. `build/initialChunkGuard.js` fails
the build when one of them reaches an initial chunk, and
`npm run check:main-chunk` holds the main chunk to its gzip budget
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js`
touches Navara's private members.
- **Navara 0.2.1 workaround.** The SDK keeps feature-change events of deleted
vector layers in its queue, so the view never reaches idle. They are dropped
each frame (`src/globe/navara/featureQueue.js`).
- **Strict acceptance.** `node scripts/qa-all-layer-switches.mjs <origin> <out>
--serve` builds the commit keyless in a clean worktree and runs every layer
and switch scenario under a deny-by-default network policy, with free map
tiles from a disk cache and a trap that fails any read of Cesium before it
has loaded. Every catalog layer must be asserted on both engines. Point
`PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned
build is missing. With an OSM base map Navara lights the globe by the real
sun, so scenarios over Austin or San Francisco see a dark frame at local
night and fail their pixel checks; run them in local daylight.
- **Navara 0.2.1 workaround.** The SDK never frees pick batch ids, and the
mover banks rebuild their instanced meshes on each attach, so every switch
leaked one id per instance of capacity (about 260 KiB of WebAssembly memory).
A per-view pool hands released ids back out
(`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS
objects and native memory stay flat.
- **Navara 0.2.1 workaround.** The first `layer.update({ show: false })` after
a vector layer is added is sometimes dropped, so a zone turned off right
after a switch stayed drawn. Polygon displays hide by deleting their layers
and rebuild them when shown (`src/layers/shared/render/navaraPolygons.js`).
Observations and grid tiles share a paced priority queue: a four-call burst,
then one request per second, at most four in flight, ≤60/minute and a persisted
8,000/day budget. Superseded consumers leave before dispatch. The recovered
queue tail isolates failures; ordinary grid failures do not fail observations.
Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles
retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and
known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED
with retry timing; logs exclude request URLs and upstream exception text.
A partial failure still draws what arrived: a failed iNaturalist request, or
one failed half of an antimeridian view, keeps a good Acartia reply and the
other half, retains that area's last iNaturalist rows and reports DEGRADED.
The update fails outright only when every requested source failed. Each failed
source keeps its HTTP status and `Retry-After` (the HTTP status counts when no
`X-Upstream-Status` is sent), and the next request waits out the longest 429 or
5xx cooldown among them. One update requests both sources, so the cooldown
holds the whole layer; a fully successful refresh clears it.
`/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N,
126–121° W, cached ten minutes and stale for at most 24 h. A valid empty
snapshot, including one whose rows all fall outside coverage or the species
checks, is a fresh success that clears earlier rows; only a malformed or failed
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
recognized cetaceans; other identified marine mammals appear under ALL or
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia
positions are rounded to 0.01° on the server. Acartia comments are never retained.
Observer identity is retained only for photo attribution.
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow,
with a view-relative density ramp from cool teal to warm amber. Overlapping
Gaussian splats span four logical cell widths; mono and CRT styles follow map
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
stay inside the globe limb with a 5% radius margin; opacity fades from full at
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates
add no per-frame work and leave sighting counts intact.
Only real sampled observation rows produce taxon badges at their public
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
5,000-observation cap. Tinted circles have a white Material Symbols glyph and
1.5 px dark outline, remaining 12–22 px at all distances, including selection.
Glyphs: birds `raven`, mammals `pets`, reptiles `egg_alt`, amphibians
`water_drop`, fishes `set_meal`, insects `bug_report`, arachnids `pest_control`,
plants `eco`, fungi `umbrella`, other animals `category`, unknown `question_mark`.
A plain circle displays while the icon font loads. Row counts read
“{n} sightings in view”, using visible grid totals when available.
Views request at most six tiles, coarsening zoom for complete coverage. Server
and client keep bounded 400-tile caches for 15 minutes. Cached, stale and
adjacent-zoom tiles publish before network waits; each arriving tile replaces
its covered area, including healthy empty tiles. Grid tiles publish even while
the independent observation request is pending or failed. Enable waits for one
camera settle; camera acquisition uses moveEnd with a 600 ms debounce. The
documented UTFGrid endpoint's unspecified cell records have a schema guard;
see `KNOWN-ISSUES.md`.
Ambient cards use a fixed 96 px image width and a short creator name credit,
with a bounded frame cache. New cards fill free screen regions; visible cards
survive a random sample refresh until they leave the view or observation
window. The 110/125 km show/hide thresholds provide hysteresis; cards scale
and fade gradually through 120 km. Only painted photos suppress their own
badge and label. Labels yield to painted cards, then compete by ambient score;
repeated translated names within 80 screen pixels yield to higher-priority
content. At most 150 labels enter the host. Titles prefer a word boundary for
ellipses; accessible names remain complete. Selected thumbnails or names use
the shared selected chrome and paint lane, with no canvas source link.
Clicking a badge, label or photo flies to its supplied position and opens a
compact anchored photo card. Selection stays pinned across camera, filter and
sample changes until explicit deselection or layer disable. Its medium image
uses `object-fit: contain`, with names, compact observed age and creator/licence.
The card is the single source link to iNaturalist. Obscured sightings also stay
text-only; Acartia stays text-only with provider credit and whale guidance,
and its source link is always `https://acartia.io/`, never a contributed URL.
Placement avoids actual HUD, toolbar and rail rectangles, panning only when
necessary while preserving zoom. Escape, close or an outside click closes the
card and clears selection. Camera/layout changes reposition the card without
an idle frame listener; shell teardown also removes its host and subscription.
Attribution appears in the layer row and Data attribution popover:
“iNaturalist contributors · licences per record”, plus Acartia when drawn.
Disable/destroy releases the glow collection, texture atlas, timers, queues and
style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
selection and card placement. Deterministic operation checks run in `npm test`;
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
## Prohibited & Restricted Airspace — FAA special-use airspace
@@ -674,6 +741,12 @@ geographic seams and releases on disable; forecast animation is not implied.
NHC movement speed is converted from miles per hour to rounded knots by the
proxy; advisory wind intensity is already in knots and is passed through.
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
Explicitly selecting a storm publishes one shared `weather-cyclones` record:
advisory provenance, source position, the English classification name and,
while the proxy serves its cache, the cache fetch time. The automatic row
selection does not claim shared context; refreshes do not re-announce it.
Clear or disable reports `deliberate`; a storm leaving the feed reports
`evicted`, with no fallback.
Consult the linked official advisory for safety decisions.
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
@@ -851,9 +924,12 @@ each side, and the empty Voice Preview suggests things to say until voice
starts. Settings opened from chat keep chat's frame and show the shared AI
provider, the quick-commands option and then the voice preferences (Voice
Preview, inactivity timeout and model tier), so they stay in reach while
Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice
Preview is off, with Back to chat; voice Settings show Back to voice. Switching
AI Provider keeps that view: Settings opened from chat stay chat's Settings,
in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice
Preview, and does nothing in chat. Closing chat ends a typed session, and the
voice panel's close turns voice off. Asking the assistant from chat to open
voice panel's close turns voice off. Opening chat or closing the voice panel
also clears a voice error left on the dock. Asking the assistant from chat to open
Voice Settings or Voice Preview puts chat away, minimized or not, and shows the
voice panel; the typed conversation and its running reply carry on, and the
chat button brings them back.
@@ -1626,6 +1702,8 @@ are owned by a renderer-free panel component. The layer manager supplies current
snapshots, lifecycle actions and row descriptors. Remount and teardown remove
listeners and row subscriptions; obsolete completions do not repaint old rows.
The clear control presents busy state while its existing action owns the transaction.
The Wildlife group follows Events and precedes Weather; Wildlife Sightings
provides its confirmed toggle, taxa presets and fixed-order, nonempty legend.
## Sky background
@@ -4269,34 +4347,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4350,8 +4429,10 @@ provider draws in one colour (`PROVIDER_COLORS` in
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first
keyless provider (KartaView), including a restored "Mapillary on" unless the link
switches the keyless providers off; filter-only updates never change the source.
With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
@@ -4365,7 +4446,7 @@ the camera, nearest first, at most 9 (the halves of a view across
the date line count a shared tile once). Lines are draped
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
and Google 3D tiles alike; cones and the photo marker are ground-clamped
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
pauses them for its Retry-After.
@@ -4729,8 +4810,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord
places Transit directly after Street Traffic and before Bike Share. Cameras and
utilities retain their own groups. The layer polls
registered feeds near the camera every 15 seconds, below a 3,000 km altitude
gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, and nine in
gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
(municipal buses and the Mobi-Rio BRT, two feeds), and nine in
Ontario — Toronto (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Mode silhouettes
@@ -4940,7 +5022,7 @@ cannot claim full acceptance.
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
prior state on exit; Space Missions refuses enablement while isolating replay
data. Cockpit enters through Contacts and does not independently enable Transit.
data. Cockpit does not need Contacts and does not independently enable Transit.
The proxy fetches registered URLs only, validates redirect origins, bounds
responses and decoding, caches successful snapshots, and backs off after failed
refreshes. A 304 confirms contact without appending history. Missing vehicles
@@ -5254,10 +5336,11 @@ silently demoting every later lookup for the session.
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`.
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
@@ -5274,7 +5357,7 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026)
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
@@ -5670,7 +5753,7 @@ and unreachable upstream (502/504) separately from road geometry.
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per frame of the globe engine (Cesium or Navara) through the globe contract. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, the unchanged Globe View destination is checked against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry fails closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
@@ -5805,8 +5888,11 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
candidates, and an unbiased retry for name mismatches. A "place, region" query
prefers a candidate named exactly for the place whose city, county, state,
candidates, and an unbiased retry for name mismatches. Name comparison folds
Latin accents while preserving letters and marks in other scripts, so a
non-Latin name spelled exactly, marks included, keeps its proximity ranking. A
leading "the" counts: "The Hague" does not take a nearby "Hague". A "place, region" query prefers a
candidate named exactly for the place whose city, county, state,
country or country code (or USA/UK) contains every word of the region, and a
settlement or administrative area over a POI when both fit. Nearby results that
fail that check are retried unbiased. After the region checks of both passes, a
@@ -5929,6 +6015,7 @@ request viewbox and continues searching without that geographic preference.
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
@@ -6162,12 +6249,21 @@ apply to `public/` assets or built-preview serving.
- Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14).
- The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver.
- The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract.
- Cockpit entry is gated to the operational Contacts context bundle. The
Context chooser must be in Contacts mode, Live Flights and Military Flights must both
be enabled, and a civilian or military aircraft must be tracked. The visible
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
flight-layer selection cannot enter a context-dependent cockpit.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
- Cockpit entry needs only the layer of the aircraft it rides and that aircraft
tracked: Live Flights for a civilian, Military Flights for a military aircraft
(the tracked info's `layerId`; `cockpitEntryAllowed` in
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
layer, Live AIS Vessels and every other layer may be turned on or off before
or during a session. A Context mode change still in flight blocks entry until
it settles. Turning off the layer the view rides ends it. Riding an aircraft
while Contacts is on adopts its layer as the user's own, as a direct selection
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
its layers going off, restores the pre-Contacts layers but keeps the ridden
one and the view; Live Signals then drops the rows Contacts built and shows
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
the same gate; voice entry still opens Contacts first, as its layer-targeted
entry navigates through Contacts.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
@@ -6341,7 +6437,26 @@ easier to meet (detection is now on more often), but does not create it.
- Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has
TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer
state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid`
overrides restored state until the user picks a chip. With no stored choice
overrides restored state until the user picks a chip. A host may offer
fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset,
default all three): only offered modes get chips (none for a single mode),
and other choices from chips, restores, share links or the query are
ignored. Without `osm` the layer never simulates: keyless or with the
status probe unreachable, loads fail with "TomTom roads need a TomTom key"
or "TomTom roads unavailable while the traffic service is unreachable" and
no roads are drawn.
- Row toggles (live mode only): after the road-source chips, Lines draws
every road with flow as a steady draped line in its bucket color (green
free, amber slow, red jam; jam first, then slow, then free, longest first,
capped at 1,500; on 3D tiles under photoreal, on terrain or tiles
otherwise), and Dots shows or hides the moving dots (hidden dots are kept,
are not offered to detection overlays, and hold no render frames).
Without live flow only a hidden Dots toggle is offered, so dots hidden by a
live choice, a restore or a share link can always be shown again.
Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the
layer state `lo` field (`l`, `d`). Toggling either restyles in place, with
no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines
is off. With no stored choice
the default is Hybrid with a TomTom key and OSM without one; share links
without a road-source choice (including older ones) likewise use the
installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key
+1 -1
View File
@@ -57,6 +57,6 @@ 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
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
expand multipart geometries into multiple entities; entity and stem counts are
different measurements. This change does not refresh or relicense the datasets.
+18
View File
@@ -10,6 +10,24 @@ For the roadmap and open backlog, see the repository issue tracker.
## Open
### Wildlife grid relies on the iNaturalist UTFGrid cell schema
Status: Open (guarded fallback)
The `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is documented in the v1 and v2
API docs (“Grid Tiles UTFGrid”), but the documented response only defines the
UTFGrid `grid`, `keys` and `data` members; the per-cell records are not
specified. Its 64×64 UTFGrid shape, keys and cell records
are validated in full at the allow-list proxy. A schema change or unavailable
tile retains its last good cells for at most one hour; a cold unavailable grid
uses sampled observations with the same badges and screen buckets, without
claiming complete worldwide coverage. Tests use
synthetic fixtures with the locally inspected tile's exact structure. Retest
and update the schema guard before accepting an upstream shape change. Grid
and observation calls share the paced queue, provider budget and 429 cooldown;
ordinary tile failures do not block observation queries.
### Street traffic can be slow/uneven when panning across dense city blocks
Status: Open (partially mitigated)
+1 -1
View File
@@ -106,7 +106,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 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 (retired) |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
| 17 | `bloomIntensity` | uint | style | |
| 18 | `bloomVersion` | uint | 2 | |
+1 -1
View File
@@ -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,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,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,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+5 -1112
View File
File diff suppressed because it is too large. Load diff
+6 -9
View File
@@ -2,7 +2,7 @@
"name": "gods-eye-view",
"private": true,
"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.",
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module",
"license": "MIT",
"author": "Bilawal Sidhu",
@@ -47,33 +47,28 @@
"build:panel": "node scripts/build-panel.mjs",
"preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs",
"test:perf": "node scripts/run-perf-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.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",
"qa:transit": "node scripts/qa-transit.mjs",
"mcp": "node server/mcp/stdio.js",
"check:main-chunk": "node scripts/check-main-chunk.mjs"
"mcp": "node server/mcp/stdio.js"
},
"dependencies": {
"@jtarrio/signals": "^0.10.0",
"@jtarrio/webrtlsdr": "^3.0.6",
"@mapbox/vector-tile": "^3.0.0",
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
"@navaramap/three": "0.2.1",
"@navaramap/three-default-plugin": "0.2.1",
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"hls.js": "^1.7.3",
"kdbush": "4.0.2",
"mapillary-js": "^4.1.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
"pinia": "^2.3.1",
"postprocessing": "6.39.4",
"satellite.js": "^6.0.2",
"three": "0.185.1",
"vue": "^3.5.44",
"vue-i18n": "^11.4.15"
},
@@ -298,6 +293,8 @@
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js",
"./agent/world-packet": "./src/agent/worldPacket.js"
"./agent/world-packet": "./src/agent/worldPacket.js",
"./layers/wildlife-sightings": "./src/layers/wildlifeSightings/index.js",
"./layers/wildlife-sightings/source": "./src/layers/wildlifeSightings/source.js"
}
}
-179
View File
@@ -1,179 +0,0 @@
// The production build replaces every `cesium` import with the global that
// /cesium/Cesium.js defines, and that script loads only when a Cesium globe
// first starts (src/app/loadCesium.js). A module that reads a Cesium binding
// while it loads would therefore throw on a page that starts on Navara. This
// check lists such reads: a reference to a binding imported from `cesium`, or
// to the `Cesium` global, that runs when its module is evaluated (top level,
// class heritage, static members, immediately invoked functions) rather than
// inside a function that runs later.
//
// node scripts/check-cesium-load-time.mjs [--json]
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { parse } from '@babel/parser';
const root = new URL('..', import.meta.url);
/** Files under src/ that the app ships (tests and test support excluded). */
export function sourceFiles() {
return execFileSync('git', ['ls-files', 'src'], {
cwd: root,
encoding: 'utf8',
})
.split('\n')
.filter(
(path) =>
/\.(?:js|mjs)$/.test(path) &&
!/\.test\.m?js$/.test(path) &&
!path.startsWith('src/testSupport/') &&
!path.includes('/testing/'),
);
}
const FUNCTION_TYPES = new Set([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
'ObjectMethod',
'ClassMethod',
'ClassPrivateMethod',
]);
/** Names a module binds to `cesium` imports. */
function cesiumBindings(program) {
const names = new Set();
for (const node of program.body) {
if (node.type !== 'ImportDeclaration' || node.source.value !== 'cesium')
continue;
for (const specifier of node.specifiers) names.add(specifier.local.name);
}
return names;
}
function isReference(node, parent, key) {
if (node.type !== 'Identifier') return false;
if (!parent) return true;
if (
(parent.type === 'MemberExpression' ||
parent.type === 'OptionalMemberExpression') &&
key === 'property' &&
!parent.computed
)
return false;
if (
(parent.type === 'ObjectProperty' ||
parent.type === 'ClassProperty' ||
parent.type === 'ObjectMethod' ||
parent.type === 'ClassMethod') &&
key === 'key' &&
!parent.computed
)
return false;
if (
parent.type === 'ImportSpecifier' ||
parent.type === 'ImportNamespaceSpecifier' ||
parent.type === 'ImportDefaultSpecifier' ||
parent.type === 'ExportSpecifier'
)
return false;
if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement')
return false;
return true;
}
/**
* Load-time references in one module. `deferred` is true below a function
* body that runs later; an immediately invoked function and a class's static
* members and heritage run at load and stay not deferred.
*/
export function loadTimeReferences(code, path = '<module>') {
const program = parse(code, {
sourceType: 'module',
plugins: ['classProperties', 'classStaticBlock', 'importAttributes'],
}).program;
const names = cesiumBindings(program);
const found = [];
// A re-export from `cesium` becomes a read of the global's property when
// the module loads.
for (const node of program.body)
if (
(node.type === 'ExportNamedDeclaration' ||
node.type === 'ExportAllDeclaration') &&
node.source?.value === 'cesium'
)
found.push({
path,
line: node.loc.start.line,
name: 'export from cesium',
});
const hasGlobal = /\bCesium\b/.test(code);
if (!names.size && !hasGlobal) return found;
// A `Cesium` global reference counts when the module does not bind it.
const watched = (name) =>
names.has(name) || (name === 'Cesium' && !names.has('Cesium'));
const visit = (node, parent, key, deferred) => {
if (!node || typeof node.type !== 'string') return;
if (node.type === 'ImportDeclaration') return;
if (!deferred && isReference(node, parent, key) && watched(node.name))
found.push({ path, line: node.loc.start.line, name: node.name });
let childDeferred = deferred;
if (FUNCTION_TYPES.has(node.type)) {
const invoked =
parent &&
(parent.type === 'CallExpression' || parent.type === 'NewExpression') &&
key === 'callee';
childDeferred = deferred || !invoked;
}
if (
(node.type === 'ClassProperty' || node.type === 'ClassPrivateProperty') &&
!node.static
)
childDeferred = true;
for (const [childKey, value] of Object.entries(node)) {
if (
childKey === 'loc' ||
childKey === 'leadingComments' ||
childKey === 'trailingComments' ||
childKey === 'innerComments'
)
continue;
// Computed keys and parameter defaults of a deferred function: its key
// is evaluated with the enclosing scope, its body later.
const keyDeferred =
FUNCTION_TYPES.has(node.type) && childKey === 'key'
? deferred
: childDeferred;
if (Array.isArray(value))
for (const child of value) visit(child, node, childKey, keyDeferred);
else if (value && typeof value === 'object')
visit(value, node, childKey, keyDeferred);
}
};
visit(program, null, null, false);
return found;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const found = [];
for (const path of sourceFiles()) {
const code = readFileSync(new URL(path, root), 'utf8');
try {
found.push(...loadTimeReferences(code, path));
} catch (error) {
console.error(`${path}: ${error.message}`);
process.exitCode = 1;
}
}
if (process.argv.includes('--json'))
console.log(JSON.stringify(found, null, 2));
else
for (const { path, line, name } of found)
console.log(`${path}:${line} reads ${name} while the module loads`);
if (found.length) {
console.error(
`${found.length} load-time Cesium read(s); a page that starts on Navara has no Cesium global yet.`,
);
process.exitCode = 1;
} else console.log('No module reads Cesium while it loads.');
}
-42
View File
@@ -1,42 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { loadTimeReferences, sourceFiles } from './check-cesium-load-time.mjs';
const reads = (code) =>
loadTimeReferences(code).map(({ line, name }) => `${line}:${name}`);
test('reads that run while a module loads are found; reads inside later code are not', () => {
assert.deepEqual(
reads(`import * as Cesium from 'cesium';
const color = Cesium.Color.RED;
export function later() { return Cesium.Color.BLUE; }
const lazy = () => Cesium.Math.PI;
class Shape { static axis = Cesium.Axis.Y; field = Cesium.Axis.Z; draw() { return Cesium; } }
(() => Cesium.VERSION)();
export const fallback = (sdk = Cesium) => sdk;`),
['2:Cesium', '5:Cesium', '6:Cesium'],
);
assert.deepEqual(
reads(`import { Cartesian3 as C3, Color } from 'cesium';
export const ORIGIN = C3.ZERO;
export function white() { return Color.WHITE; }`),
['2:C3'],
);
assert.deepEqual(reads(`export { Axis } from 'cesium';`), [
'1:export from cesium',
]);
assert.deepEqual(
reads(`window.__CESIUM__ = Cesium;`),
['1:Cesium'],
'the global counts where the module does not import it',
);
assert.deepEqual(reads(`const geo = { Cesium: 1 }; geo.Cesium;`), []);
});
test('no shipped module reads Cesium while it loads', () => {
const found = sourceFiles().flatMap((path) =>
loadTimeReferences(readFileSync(path, 'utf8'), path),
);
assert.deepEqual(found, []);
});
+2 -148
View File
@@ -19,63 +19,6 @@ const tests = (file) =>
const source = (file) =>
file.startsWith('src/sources/') ||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
const geo = (file) => file.startsWith('src/geo/');
// The app clock (src/time): plain numbers, no engine, no DOM.
const time = (file) => file.startsWith('src/time/');
// The engine-neutral globe contract and its test support; engines live below it.
const globeContract = (file) =>
/^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/');
const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
// Navara renders through three.js and its postprocessing peer.
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// Layers built on layer renderers keep each engine in their renderers:
// cesium only in render/cesium*.js, three and Navara only in
// render/navara*.js. The list grows with each layer moved onto renderers.
const RENDERED_LAYER_DIRS = [
'src/layers/alpr/',
'src/layers/airQuality/',
'src/layers/gnss/',
'src/layers/perimeters/',
'src/layers/radio/',
'src/layers/bikeshare/',
'src/layers/earthquakes/',
'src/layers/firms/',
'src/layers/flights/',
'src/layers/military/',
'src/layers/localAdsb/',
'src/layers/vessels/',
'src/layers/satellites/',
'src/layers/launches/',
'src/layers/aircraft/',
'src/layers/shared/',
];
const W1_SHARED_FILES = new Set(
[
'trackedCamera',
'trailRenderer',
'contactTrailRenderer',
'focusDeemphasis',
'spriteOrder',
'iconOrientation',
'modelVisualAnchor',
'trackingClickGesture',
].map((name) => `src/data/${name}.js`),
);
const renderedLayer = (file) =>
W1_SHARED_FILES.has(file) ||
file.startsWith('src/data/render/') ||
file === 'src/celestialRing.js' ||
file.startsWith('src/celestialRing/') ||
RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir));
const cesiumLayerRenderer = (file) =>
/\/render\/cesium(?:[^/]*|\/[^/]+)\.js$/.test(file);
// Navara renderers outside a layer's render/ directory: like those, they
// reach the app only through the Navara mode's import().
const NAVARA_SCENE_RENDERERS = new Set(['src/scenes/dataPacks/navara.js']);
const navaraLayerRenderer = (file) =>
/\/render\/navara[^/]*\.js$/.test(file) || NAVARA_SCENE_RENDERERS.has(file);
const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
@@ -144,9 +87,7 @@ export function checkImportDirections(root) {
}
let analysis;
try {
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
dynamic: true,
});
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
} catch (error) {
report(file, error.message);
return null;
@@ -168,18 +109,6 @@ export function checkImportDirections(root) {
target = path.resolve(root, exported);
} else {
record.external.push(specifier);
if (renderedLayer(file) && !tests(file)) {
if (cesiumPackage.test(specifier) && !cesiumLayerRenderer(file))
report(
file,
`cesium only in the layer's render/cesium*.js: ${specifier}`,
);
if (navaraPackage.test(specifier) && !navaraLayerRenderer(file))
report(
file,
`three and Navara only in the layer's render/navara*.js: ${specifier}`,
);
}
if (
file.startsWith('src/') &&
(builtins.has(specifier) || specifier.startsWith('node:'))
@@ -202,32 +131,6 @@ export function checkImportDirections(root) {
continue;
}
record.edges.push(to);
// Navara stays out of the Cesium-mode bundle: outside its own directory
// it is reached only through import() (src/app/scene.js), which the
// bundler splits into a chunk loaded on ?engine=navara.
if (
to.startsWith('src/globe/navara/') &&
!file.startsWith('src/globe/navara/') &&
!analysis.dynamicImports.includes(specifier)
)
report(
file,
`The Navara engine loads only through a dynamic import(): ${to}`,
);
// Like the engine, a layer's Navara renderer reaches the app only
// through the Navara mode's import() (src/app/navaraMode.js).
if (
navaraLayerRenderer(to) &&
!navaraLayerRenderer(file) &&
!(
file === 'src/app/navaraMode.js' &&
analysis.dynamicImports.includes(specifier)
)
)
report(
file,
`A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`,
);
if (file.startsWith('src/') && to.startsWith('server/'))
report(file, `Browser imports server: ${to}`);
if (
@@ -274,8 +177,6 @@ export function checkImportDirections(root) {
for (const file of files.filter((file) => file.startsWith('src/')))
browser(file);
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
for (const file of files.filter(geo)) roots.set(file, 'geo');
for (const file of files.filter(time)) roots.set(file, 'time');
for (const [key, value] of Object.entries(pkg.exports || {})) {
if (portableExport(key) && typeof value === 'string')
roots.set(value.replace(/^\.\//, ''), key);
@@ -301,12 +202,7 @@ export function checkImportDirections(root) {
if (
builtins.has(specifier) ||
specifier.startsWith('node:') ||
/^(?:cesium|vite)(?:\/|$)/.test(specifier) ||
// Geo must not reach Cesium through its workspace packages either.
(label === 'geo' && /^@cesium\//.test(specifier)) ||
// Neither engine reaches the app clock.
(label === 'time' &&
(cesiumPackage.test(specifier) || navaraPackage.test(specifier)))
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
)
report(
file,
@@ -321,48 +217,6 @@ export function checkImportDirections(root) {
}
visit(start);
}
// The globe contract reaches no engine, directly or through helpers; each
// engine implementation imports only its own engine's packages.
const globeSeen = new Set();
function globe(file) {
if (globeSeen.has(file) || !code.test(file)) return;
globeSeen.add(file);
const info = load(file);
if (!info) return;
for (const specifier of info.external) {
if (cesiumPackage.test(specifier) || navaraPackage.test(specifier))
report(file, `globe contract reaches engine dependency: ${specifier}`);
}
for (const to of info.edges) {
if (globeEngine(to))
report(file, `globe contract imports engine implementation: ${to}`);
else globe(to);
}
}
for (const file of files.filter(globeContract)) globe(file);
// Each engine graph stays on its own engine, through helpers too.
for (const start of files.filter(globeEngine)) {
const navara = start.startsWith('src/globe/navara/');
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
const foreignPackage = navara ? cesiumPackage : navaraPackage;
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
const seen = new Set();
const visit = (file) => {
if (seen.has(file) || !code.test(file)) return;
seen.add(file);
const info = load(file);
if (!info) return;
for (const specifier of info.external)
if (foreignPackage.test(specifier))
report(file, `${label} reaches ${specifier}`);
for (const to of info.edges) {
if (to.startsWith(foreignEngine))
report(file, `${label} reaches ${to}`);
else visit(to);
}
};
visit(start);
}
// Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set();
function voice(file) {
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env node
/**
* The size budget of the Cesium-mode main chunk (run after `npm run build`):
* gzip-9 of the entry chunk the built page loads (from the Vite manifest, or
* the module script dist/index.html loads) may exceed the baseline in
* scripts/main-chunk-baseline.json by at most its allowance. What the initial
* chunks may hold is the build's own check (build/initialChunkGuard.js).
*
* Prints the size. `--json` prints the whole result; `--dist=DIR` reads
* another build.
*
* npm run build && npm run check:main-chunk
*/
import fs from 'node:fs';
import path from 'node:path';
import { gzipSync } from 'node:zlib';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { entryFromHtml, entryFromManifest } from '../src/tooling/mainChunk.js';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
/** The verdict for a main-chunk size against the baseline. */
export function checkMainChunk(
gzipBytes,
{ gzipBytes: baseline, allowanceBytes },
) {
const limit = baseline + allowanceBytes;
const growth = gzipBytes - baseline;
return {
ok: gzipBytes <= limit,
message: `main chunk ${gzipBytes} bytes gzip-9 (${growth >= 0 ? '+' : ''}${growth} over the ${baseline} baseline; limit ${limit})`,
};
}
/**
* gzip-9 size of the main chunk, with its name: the entry the built page
* loads (`entry`, else found from the Vite manifest or dist/index.html).
*/
export function measureMainChunk(distDir, { entry = null } = {}) {
const file = entry ?? findEntry(distDir).entry;
const bytes = fs.readFileSync(path.join(distDir, file));
return {
name: path.basename(file),
gzipBytes: gzipSync(bytes, { level: 9 }).length,
};
}
/** The entry chunk (relative to dist) and how it was found. */
function findEntry(dist) {
const manifestPath = path.join(dist, '.vite', 'manifest.json');
if (fs.existsSync(manifestPath)) {
const entry = entryFromManifest(
JSON.parse(fs.readFileSync(manifestPath, 'utf8')),
);
if (entry) return { entry, via: 'manifest' };
}
const htmlPath = path.join(dist, 'index.html');
if (!fs.existsSync(htmlPath))
throw new Error(`No build at ${dist}: run npm run build first`);
const entry = entryFromHtml(fs.readFileSync(htmlPath, 'utf8'));
if (!entry) throw new Error(`${htmlPath} loads no module script`);
return { entry, via: 'index.html' };
}
function main() {
const option = (name) =>
process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
const dist = path.resolve(ROOT, option('dist') ?? 'dist');
const { entry, via } = findEntry(dist);
const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json');
let budget = null;
if (fs.existsSync(baselinePath)) {
const measured = measureMainChunk(dist, { entry });
budget = {
...measured,
...checkMainChunk(
measured.gzipBytes,
JSON.parse(fs.readFileSync(baselinePath, 'utf8')),
),
};
}
const ok = budget?.ok ?? true;
if (process.argv.includes('--json')) {
console.log(JSON.stringify({ entry, via, budget, ok }, null, 2));
} else {
console.log(`[check:main-chunk] entry ${entry} (via ${via})`);
console.log(
budget
? `[check:main-chunk] ${budget.name}: ${budget.message}`
: '[check:main-chunk] no scripts/main-chunk-baseline.json: size budget not checked',
);
}
if (!ok) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main();
+3 -11
View File
@@ -37,11 +37,7 @@ export async function checkPackageBoundaries(root) {
) {
throw new Error(`Invalid package boundary: ${name}`);
}
// A module entry ending in "/" owns every file under that directory.
const allowed = new Set();
const allowedDirectories = [];
const owns = (id) =>
allowed.has(id) || allowedDirectories.some((dir) => id.startsWith(dir));
for (const module of group.modules) {
if (
typeof module !== 'string' ||
@@ -60,11 +56,7 @@ export async function checkPackageBoundaries(root) {
) {
throw new Error(`Boundary module escapes repository: ${name}`);
}
if (module.endsWith('/')) {
allowedDirectories.push(`${normalizePath(resolved)}/`);
} else {
allowed.add(normalizePath(resolved));
}
allowed.add(normalizePath(resolved));
}
for (const external of group.external) {
if (
@@ -90,7 +82,7 @@ export async function checkPackageBoundaries(root) {
if (
typeof target !== 'string' ||
!target.startsWith('./') ||
!owns(normalizePath(path.resolve(root, target)))
!allowed.has(normalizePath(path.resolve(root, target)))
) {
throw new Error(`Export must point to an owned module: ${key}`);
}
@@ -117,7 +109,7 @@ export async function checkPackageBoundaries(root) {
// template blocks belong to the component file itself.
if (info.id.startsWith('\0')) return;
const id = info.id.split('?')[0];
if (!owns(id)) {
if (!allowed.has(id)) {
throw new Error(
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
);
+1 -1
View File
@@ -250,7 +250,7 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then
fi
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
! grep -q "createApplicationCctv(" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
echo "error: CCTV layer not wired in src/standalone/catalog.js"
exit 1
fi
Binary file not shown.
-132
View File
@@ -1,132 +0,0 @@
import { PbfWriter } from 'pbf';
// Recorded journeys cover both a dense neighbourhood and the wider Austin view.
export const cameras = [
...Array.from({ length: 225 }, (_, i) => ({
latitude: 30.262 + (Math.floor(i / 15) - 7) * 0.003,
longitude: -97.7431 + ((i % 15) - 7) * 0.003,
})),
...Array.from({ length: 100 }, (_, i) => ({
latitude: 30.308 + (Math.floor(i / 10) - 4.5) * 0.001,
longitude: -97.77 + ((i % 10) - 4.5) * 0.001,
})),
].map((position, i) => ({
...position,
osmId: 9000000 + i,
operator: i % 3 ? 'W2 fixture' : 'Cámara pública',
brand: 'Fixture camera',
cameraType: 'fixed',
direction: i % 5 ? String((i * 37) % 360) : '',
}));
export function cameraTile(z, x, y, records = cameras) {
const extent = 4096;
const scale = 2 ** z;
const rows = records.flatMap((record) => {
const latitude = (record.latitude * Math.PI) / 180;
const tx = ((record.longitude + 180) / 360) * scale;
const ty = ((1 - Math.asinh(Math.tan(latitude)) / Math.PI) / 2) * scale;
if (Math.floor(tx) !== x || Math.floor(ty) !== y) return [];
return [
{
record,
x: Math.round((tx - x) * extent),
y: Math.round((ty - y) * extent),
},
];
});
const keys = [
'osmType',
'osmId',
'operator',
'brand',
'cameraType',
'direction',
];
const writer = new PbfWriter();
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'cameras');
layer.writeVarintField(5, extent);
for (const key of keys) layer.writeStringField(3, key);
rows.forEach(({ record, x, y }, index) => {
for (const key of keys)
layer.writeMessage(
4,
(_, value) =>
value.writeStringField(
1,
key === 'osmType' ? 'node' : String(record[key]),
),
null,
);
layer.writeMessage(
2,
(_, feature) => {
feature.writeVarintField(1, record.osmId);
feature.writePackedVarint(
2,
keys.flatMap((_, i) => [i, index * keys.length + i]),
);
feature.writeVarintField(3, 1);
feature.writePackedVarint(4, [9, x * 2, y * 2]);
},
null,
);
});
},
null,
);
return Buffer.from(writer.finish());
}
/** Real TileJSON/MVT transport; no replacement of the layer or source parser. */
export async function installAlprFixture(page) {
await page.setRequestInterception(true);
page.on('request', (request) => {
if (request.isInterceptResolutionHandled()) return;
const url = new URL(request.url());
// The journey also asks for nearby-place labels while moving. Pin that
// auxiliary service; these runs do not test Google Places availability.
if (
['localhost', '127.0.0.1'].includes(url.hostname) &&
url.pathname === '/api/google/nearby-places'
) {
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ places: [] }),
});
return;
}
if (url.hostname !== 'tiles.dontgetflocked.com') {
void request.continue();
return;
}
const headers = { 'access-control-allow-origin': '*' };
if (url.pathname.endsWith('.json')) {
const country = url.pathname.includes('-ca-') ? 'ca' : 'us';
void request.respond({
status: 200,
headers,
contentType: 'application/json',
body: JSON.stringify({
tiles: [
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
],
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
}),
});
return;
}
const match = url.pathname.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
void request.respond({
status: match ? 200 : 404,
headers,
contentType: 'application/x-protobuf',
body: match ? cameraTile(...match.slice(1).map(Number)) : '',
});
});
}
-197
View File
@@ -1,197 +0,0 @@
import sharp from 'sharp';
// Transport fixtures are parsed by the real CCTV catalog and media owners.
export const cameras = Array.from({ length: 25 }, (_, i) => ({
id: `w2-cctv-${String(i).padStart(3, '0')}`,
name: i === 12 ? 'Cámara pública — Congress' : `W2 camera ${i}`,
cityId: 'austin',
city: 'Austin',
lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018,
lon: -97.7431 + ((i % 5) - 2) * 0.0018,
headingDeg: (i * 37) % 360,
fovDeg: i % 2 ? 44 : 56,
rangeM: i % 2 ? 145 : 210,
mountHeightM: i % 2 ? 8 : 10,
pitchDeg: i % 2 ? -18 : -24,
groundElevationM: 150,
headingConfidence: i % 3 ? 'medium' : 'low',
sourceKind: 'configured',
feedType: 'image',
provider: 'W2 deterministic fixture',
url: 'https://cctv-fixture.invalid/frame.png',
}));
export async function cameraFrame() {
const width = 320,
height = 180;
const pixels = Buffer.alloc(width * height * 3);
const colors = [
[240, 45, 70],
[35, 220, 90],
[40, 90, 235],
[245, 210, 30],
];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const color =
colors[(x >= width / 2 ? 1 : 0) + (y >= height / 2 ? 2 : 0)];
const grid = x % 40 < 2 || y % 30 < 2;
const at = (y * width + x) * 3;
for (let c = 0; c < 3; c++) pixels[at + c] = grid ? 15 : color[c];
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
export async function installCctvFixture(page, { video } = {}) {
const frame = await cameraFrame();
const catalog = cameras.map((camera) => ({ ...camera }));
if (video)
catalog.push({
...cameras[0],
id: 'w2-cctv-video',
name: 'W2 moving video',
lat: 30.2718,
lon: -97.744,
feedType: 'video',
url: 'https://cctv-fixture.invalid/camera.mp4',
});
const ids = new Set(catalog.map((camera) => camera.id));
// Global Puppeteer interception can stall Cesium's module workers in
// Chromium 152. Intercept only fixture API routes on the page's CDP session.
const session = await page.createCDPSession();
let disposed = false;
const routes = [
'/api/cctv/*',
'/api/flights*',
'/api/military*',
'/api/vessels*',
'/api/google/nearby-places*',
];
await session.send('Fetch.enable', {
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
routes.map((route) => ({ urlPattern: `http://${host}:*${route}` })),
),
});
const respond = (
requestId,
{ status, contentType, headers = {}, body = '' },
) =>
session.send('Fetch.fulfillRequest', {
requestId,
responseCode: status,
responseHeaders: Object.entries({
...(contentType ? { 'content-type': contentType } : {}),
...headers,
}).map(([name, value]) => ({ name, value: String(value) })),
body: Buffer.from(body).toString('base64'),
});
const handle = (request) => {
const url = new URL(request.url());
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
return request.continue();
}
const json = (body) =>
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
const auxiliary = {
'/api/flights': { states: [] },
'/api/military': { ac: [] },
'/api/vessels': { connected: false, vessels: [] },
};
if (auxiliary[url.pathname]) {
return json(auxiliary[url.pathname]);
}
if (url.pathname === '/api/cctv/sources') {
return json({ sources: catalog });
}
if (url.pathname === '/api/cctv/health') {
return json({
cameras: catalog.map(({ id, feedType }) => ({
id,
status: 'online',
sourceKind: feedType,
label: 'W2 deterministic fixture',
updatedAt: Date.now(),
})),
});
}
const media = url.pathname.match(/^\/api\/cctv\/(frame|media)\/([^/]+)$/);
if (media) {
const id = decodeURIComponent(media[2]);
if (
!ids.has(id) ||
(media[1] === 'media' && (id !== 'w2-cctv-video' || !video))
) {
return request.respond({ status: 404, body: 'Unknown fixture camera' });
}
if (media[1] === 'frame') {
return request.respond({
status: 200,
contentType: 'image/png',
body: frame,
});
}
const range = request.headers().range?.match(/^bytes=(\d+)-(\d*)$/);
const start = range ? Number(range[1]) : 0;
const end =
range && range[2]
? Math.min(Number(range[2]), video.length - 1)
: video.length - 1;
if (start > end || start >= video.length) {
return request.respond({
status: 416,
headers: { 'content-range': `bytes */${video.length}` },
});
}
return request.respond({
status: range ? 206 : 200,
contentType: 'video/mp4',
headers: {
'accept-ranges': 'bytes',
...(range
? { 'content-range': `bytes ${start}-${end}/${video.length}` }
: {}),
},
body: video.subarray(start, end + 1),
});
}
if (url.pathname === '/api/google/nearby-places') {
return json({ places: [] });
}
return request.continue();
};
session.on('Fetch.requestPaused', (event) => {
const request = {
url: () => event.request.url,
headers: () =>
Object.fromEntries(
Object.entries(event.request.headers).map(([name, value]) => [
name.toLowerCase(),
value,
]),
),
respond: (response) => respond(event.requestId, response),
continue: () =>
session.send('Fetch.continueRequest', { requestId: event.requestId }),
};
Promise.resolve()
.then(() => handle(request))
.catch((error) => {
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
});
});
return {
cameras: catalog,
frame,
async dispose() {
disposed = true;
await session.detach();
},
};
}
-273
View File
@@ -1,273 +0,0 @@
import { cameras as alprCameras, cameraTile } from './alpr.mjs';
import { cameras as cctvCameras, cameraFrame } from './cctv.mjs';
import { stations as radioStations } from './radio.mjs';
import { earthquakePerformanceFixture } from './earthquakes.mjs';
export const COMBINED_EPOCH = Date.parse('2026-10-28T18:00:00Z');
export const COMBINED_ORIGIN = { lat: 42.3554, lon: -71.0605 };
const grid = (i, cols, spacing) => ({
lat: COMBINED_ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
lon: COMBINED_ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
});
const radio = radioStations.map((r, i) => ({ ...r, ...grid(i, 28, 0.001) }));
const cctv = cctvCameras.map((r, i) => ({
...r,
...grid(i, 5, 0.002),
cityId: 'boston',
city: 'Boston',
groundElevationM: 20,
}));
const alpr = alprCameras.map((r, i) => {
const p = grid(i, 19, 0.001);
return { ...r, latitude: p.lat, longitude: p.lon };
});
const bikes = Array.from({ length: 200 }, (_, i) => ({
station_id: `w2-bike-${i}`,
name: `W2 station ${i}`,
capacity: 20,
...grid(i, 15, 0.0015),
}));
const earthquakes = earthquakePerformanceFixture(500);
earthquakes.features = earthquakes.features.map((r, i) => {
const p = grid(i, 23, 0.002);
return {
...r,
properties: { ...r.properties, time: COMBINED_EPOCH - 3600000 },
geometry:
i < 5 ? { ...r.geometry, coordinates: [p.lon, p.lat, 5] } : r.geometry,
};
});
const fires = Array.from({ length: 6000 }, (_, i) => ({
...grid(i, 78, 0.001),
frp: 5 + (i % 120),
confidence: ['low', 'nominal', 'high'][i % 3],
brightness: 280 + (i % 90),
daynight: i % 2 ? 'N' : 'D',
acqDate: '2026-10-28',
acqTime: '1730',
instrument: 'VIIRS',
satellite: 'NOAA-20',
}));
const elements = Array.from({ length: 20 }, (_, i) => {
const p = grid(i, 5, 0.006),
d = 0.0007;
return {
type: 'way',
id: 980000 + i,
center: p,
tags: { military: 'airfield', name: `W2 facility ${i}` },
bounds: {
minlat: p.lat - d,
minlon: p.lon - d,
maxlat: p.lat + d,
maxlon: p.lon + d,
},
geometry: [
[p.lon - d, p.lat - d],
[p.lon + d, p.lat - d],
[p.lon + d, p.lat + d],
[p.lon - d, p.lat + d],
[p.lon - d, p.lat - d],
].map(([lon, lat]) => ({ lon, lat })),
};
});
export const combinedPayloads = {
radio,
cctv,
alpr,
bikes,
earthquakes,
fires,
elements,
};
/** One source transport fixture, shared unchanged by both engines. */
export async function installCombinedFixture(
page,
{ denseQuakes = false } = {},
) {
const quakePayload = denseQuakes
? {
...earthquakes,
features: earthquakes.features.map((r, i) => {
const p = grid(i, 23, 0.002);
return {
...r,
geometry: { ...r.geometry, coordinates: [p.lon, p.lat, 5] },
};
}),
}
: earthquakes;
const frame = await cameraFrame(),
requests = [];
const session = await page.createCDPSession();
await session.send('Fetch.enable', {
patterns: [
...['127.0.0.1', 'localhost'].map((host) => ({
urlPattern: `http://${host}:*/api/*`,
})),
{ urlPattern: 'https://earthquake.usgs.gov/*all_day.geojson' },
{ urlPattern: 'https://tiles.dontgetflocked.com/*' },
{ urlPattern: '*://*.earthdata.nasa.gov/*' },
],
});
let disposed = false;
session.on('Fetch.requestPaused', async (event) => {
try {
const u = new URL(event.request.url);
let body,
type = 'application/json';
const p = u.pathname;
if (u.hostname === 'earthquake.usgs.gov') body = quakePayload;
else if (u.hostname === 'tiles.dontgetflocked.com') {
if (p.endsWith('.json'))
body = {
tiles: [
'https://tiles.dontgetflocked.com/cameras-us-hourly/{z}/{x}/{y}.mvt',
],
bounds: [-160, 17, -64, 59],
};
else {
const m = p.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
body = m
? cameraTile(...m.slice(1).map(Number), alpr)
: Buffer.alloc(0);
type = 'application/x-protobuf';
}
} else if (u.hostname === 'cmr.earthdata.nasa.gov')
body = {
hits: 2,
items: ['2026-10-28', '2026-10-27'].map((day) => ({
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` },
},
AdditionalAttributes: [{ Name: 'CLOUD_COVERAGE', Values: ['0'] }],
SpatialExtent: {
HorizontalSpatialDomain: {
Geometry: {
BoundingRectangles: [
{
WestBoundingCoordinate: -72,
EastBoundingCoordinate: -70,
SouthBoundingCoordinate: 41,
NorthBoundingCoordinate: 43,
},
],
},
},
},
},
})),
};
else if (u.hostname.endsWith('.earthdata.nasa.gov')) {
body = frame;
type = 'image/png';
} else if (p === '/api/firms')
body = { fires, fetchedAt: COMBINED_EPOCH - 120000, stale: false };
else if (p === '/api/radio/stations')
body = {
stations: radio,
updatedAt: new Date(COMBINED_EPOCH).toISOString(),
stale: false,
degraded: false,
acceptedGeneration: 1,
catalogInstance: 'w2-combined-fixture',
};
else if (p.startsWith('/api/gbfs/'))
body = {
version: '3.0',
data: {
stations: decodeURIComponent(u.href).includes('station_information')
? bikes
: bikes.map(({ station_id }) => ({
station_id,
num_bikes_available: 7,
num_docks_available: 13,
is_renting: true,
is_returning: true,
})),
},
};
else if (p === '/api/military-installations')
body = {
status: 'ready',
retrievedAt: new Date(COMBINED_EPOCH).toISOString(),
saturated: false,
elements,
};
else if (p === '/api/cctv/sources') body = { sources: cctv };
else if (p === '/api/cctv/health')
body = {
cameras: cctv.map(({ id }) => ({
id,
status: 'online',
sourceKind: 'image',
updatedAt: COMBINED_EPOCH,
})),
};
else if (p.startsWith('/api/cctv/frame/')) {
body = frame;
type = 'image/png';
} else if (p.startsWith('/api/transit/'))
body = {
fetchedAt: COMBINED_EPOCH,
vehicles: [],
fixes: [],
epochs: [],
};
else if (p === '/api/flights') body = { states: [] };
else if (p === '/api/military') body = { ac: [] };
else if (p === '/api/vessels') body = { connected: false, vessels: [] };
else if (p === '/api/google/nearby-places') body = { places: [] };
else if (p === '/api/setup/status') body = { ready: true };
else if (p === '/api/openai/hud-summary')
body = { summary: 'W2 performance fixture' };
else if (p === '/api/terrain/heights')
body = {
results: (u.searchParams.get('points') || '')
.split(';')
.filter(Boolean)
.map((point) => {
const [lon, lat] = point.split(',').map(Number);
return { lon, lat, elevation: 20, geoid: 0, ellipsoid: 20 };
}),
};
if (body === undefined) {
await session.send('Fetch.continueRequest', {
requestId: event.requestId,
});
return;
}
requests.push(p);
await session.send('Fetch.fulfillRequest', {
requestId: event.requestId,
responseCode: 200,
responseHeaders: [
{ name: 'Content-Type', value: type },
{ name: 'Access-Control-Allow-Origin', value: '*' },
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
{ name: 'Data-Present', value: 'true' },
],
body: (Buffer.isBuffer(body)
? body
: Buffer.from(JSON.stringify(body))
).toString('base64'),
});
} catch (error) {
if (
!disposed &&
!page.isClosed() &&
!String(error.message).includes('Invalid InterceptionId')
)
page.emit('pageerror', error);
}
});
return {
requests,
async dispose() {
disposed = true;
await session.detach();
},
};
}
-83
View File
@@ -1,83 +0,0 @@
// USGS-shaped fixed observations; source parsers remain the production ones.
export const W2_EPOCH_MS = 1792152000000;
export function earthquakeFixture() {
const features = [];
for (const [region, lon, lat] of [
['sf', -122.42, 37.77],
['austin', -97.74, 30.27],
['alaska', -150.4, 61.0],
]) {
for (let i = 0; i < 5; i++) {
features.push({
type: 'Feature',
id: `qa-${region}-${i}`,
geometry: {
type: 'Point',
coordinates: [
lon + (i - 2) * 0.24,
lat + (i % 2) * 0.3,
[8, 70, 300, 301, 45][i],
],
},
properties: {
mag: [2.5, 3, 4, 5, 5.5][i],
place: `W2 ${region} event ${i}`,
time: W2_EPOCH_MS - i * 60000,
},
});
}
}
features.push({
type: 'Feature',
id: 'qa-below',
geometry: { type: 'Point', coordinates: [-122.42, 37.77, 5] },
properties: { mag: 2.4 },
});
features.push({
type: 'Feature',
id: 'qa-missing-mag',
geometry: { type: 'Point', coordinates: [-122.4, 37.7, 5] },
properties: { mag: null },
});
return {
type: 'FeatureCollection',
metadata: { generated: W2_EPOCH_MS },
features,
};
}
/** Dense regional and worldwide USGS-shaped load, separate from the oracle. */
export function earthquakePerformanceFixture(count = 500) {
let seed = 17;
const random = () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
};
return {
type: 'FeatureCollection',
features: Array.from({ length: count }, (_, i) => ({
type: 'Feature',
id: `perf-quake-${i}`,
properties: {
mag: 2.5 + (i % 7) * 0.5,
place: `Recorded performance event ${i}`,
time: W2_EPOCH_MS - i * 1000,
},
geometry: {
type: 'Point',
coordinates:
i < Math.min(100, count)
? [
-122.42 + (random() - 0.5),
37.77 + (random() - 0.5),
[8, 70, 301][i % 3],
]
: [
-180 + random() * 360,
-70 + random() * 140,
[8, 70, 301][i % 3],
],
},
})),
};
}
-57
View File
@@ -1,57 +0,0 @@
// Real /api/firms envelope; fixed source receipt and acquisition clocks.
export const FIRMS_EPOCH_MS = Date.UTC(2026, 9, 16, 12);
export const FIRMS_VALID_COUNT = 371;
export function firmsFixture() {
const fires = [];
const regions = [
[37.77, -122.42],
[30.27, -97.74],
[61.2, -149.9],
];
for (const [region, [latitude, longitude]] of regions.entries()) {
for (let row = -5; row <= 5; row++) {
for (let column = -5; column <= 5; column++) {
const index = fires.length;
fires.push({
lat: latitude + row * 0.12,
lon: longitude + column * 0.12,
frp:
region === 0 && row === 0 && column === 0 ? 999 : 5 + (index % 120),
confidence: ['low', 'nominal', 'high', 85][index % 4],
brightness: 280 + (index % 90),
daynight: index % 2 ? 'N' : 'D',
acqDate: '2026-10-16',
acqTime: String(800 + (index % 60)),
instrument: index % 4 === 3 ? 'MODIS' : 'VIIRS',
satellite: index % 4 === 3 ? 'Terra' : 'NOAA-20',
});
}
}
}
for (const [lat, lon] of [
[10, 179.7],
[10, -179.7],
[89.5, 45],
[-89.5, -135],
[0.2, 179.8],
[-0.2, -179.8],
[70, 179.5],
[-70, -179.5],
]) {
fires.push({
lat,
lon,
frp: 30,
confidence: 'high',
brightness: 320,
daynight: 'D',
acqDate: '2026-10-16',
acqTime: '930',
instrument: 'VIIRS',
satellite: 'NOAA-20',
});
}
fires.push({ lat: 'invalid', lon: 0 }, { lat: 0, lon: 'invalid' });
return { fires, fetchedAt: FIRMS_EPOCH_MS - 120000, stale: false };
}
-109
View File
@@ -1,109 +0,0 @@
// Shared 750-station directory for legacy and both-engine radio QA.
const tags = [
'news',
'talk',
'weather,emergency',
'public safety,scanner',
'aviation,atc',
'marine,maritime',
'traffic,transit',
'music,jazz',
'music,rock',
'community',
];
export const stations = Array.from({ length: 750 }, (_, index) => {
const row = Math.floor(index / 30);
const column = index % 30;
return {
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
name:
index === 360
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
: index === 390
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
lat: -72 + row * 6,
lon: -174 + column * 12,
streamUrl: `https://audio.example.test/${index}.mp3`,
homepage: `https://station.example.test/${index}`,
tags: tags[index % tags.length].split(','),
languages: index === 360 ? ['Spanish'] : ['English'],
state: `Region ${row + 1}`,
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: index % 2 ? 'AAC' : 'MP3',
bitrate: 128,
};
});
export async function installRadioFixture(page) {
await page.evaluateOnNewDocument((stations) => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaRadio = {
stations,
generation: 1,
plays: [],
delay: 0,
response: 'normal',
pending: null,
};
HTMLMediaElement.prototype.play = function () {
window.qaPlayer = this;
window.__qaRadio.plays.push(this.src);
if (window.__qaRadio.delayPlay) {
window.__qaRadio.delayPlay = false;
return new Promise((resolve) => {
window.__qaRadio.releasePlay = () => {
this.dispatchEvent(new Event('playing'));
resolve();
};
});
}
queueMicrotask(() => this.dispatchEvent(new Event('playing')));
return Promise.resolve();
};
const fetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const address = String(
typeof input === 'string' || input instanceof URL ? input : input.url,
);
if (address.includes('/api/radio/stations')) {
const qa = window.__qaRadio;
qa.reads = (qa.reads || 0) + 1;
const response = {
stations: qa.response === 'empty' ? [] : qa.stations,
updatedAt: new Date().toISOString(),
stale: false,
degraded: false,
acceptedGeneration: qa.generation,
catalogInstance: 'w2-radio-fixture',
};
const mode = qa.response;
const delay = qa.delay;
qa.delay = 0;
if (delay)
await new Promise((resolve) => {
qa.pending = resolve;
});
init?.signal?.throwIfAborted();
return new Response(
mode === 'failure' ? '{}' : JSON.stringify(response),
{
status: mode === 'failure' ? 503 : 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (address.includes('/api/radio/click/'))
return new Response(null, { status: 204 });
if (address.includes('/api/flights'))
return new Response('{"states":[]}');
if (address.includes('/api/military')) return new Response('{"ac":[]}');
if (address.includes('/api/vessels'))
return new Response('{"connected":false,"vessels":[]}');
return fetch(input, init);
};
}, stations);
}
-100
View File
@@ -1,100 +0,0 @@
export const TRANSIT_EPOCH = 1_793_232_000_000;
const routes = ['1', 'Green-B', 'Red', 'CR-Fitchburg', 'Boat-F1', ''];
export function transitSnapshot(step = 8) {
const fetchedAt = TRANSIT_EPOCH - 135000 + step * 15000;
return {
fetchedAt,
vehicles: [
...Array.from({ length: 12 }, (_, i) => ({
id: `w2-${i}`,
routeId: routes[i % 6],
tripId: `trip-${i}`,
lat:
42.355 +
(Math.floor(i / 4) - 1) * 0.0017 +
(i < 6 ? step * 0.00012 : 0),
lon: -71.065 + ((i % 4) - 1.5) * 0.0017,
timestamp: fetchedAt / 1000,
timestampSource: 'vehicle',
bearing: 0,
})),
{
id: 'stale',
routeId: '1',
tripId: 'old',
lat: 42.355,
lon: -71.065,
timestamp: (TRANSIT_EPOCH - 900000) / 1000,
timestampSource: 'vehicle',
},
],
};
}
export async function installTransitFixture(page) {
let step = 0,
disposed = false;
const requests = [];
const session = await page.createCDPSession();
await page.evaluateOnNewDocument((epoch) => {
Date.now = () => epoch + performance.now();
}, TRANSIT_EPOCH);
await session.send('Fetch.enable', {
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
[
'/api/transit/*',
'/api/flights*',
'/api/military*',
'/api/vessels*',
].map((route) => ({ urlPattern: `http://${host}:*${route}` })),
),
});
session.on('Fetch.requestPaused', async (event) => {
try {
const url = new URL(event.request.url);
requests.push(url.pathname);
let body;
if (url.pathname.startsWith('/api/transit/vehicles/'))
body = url.pathname.endsWith('/mbta')
? transitSnapshot(step)
: { fetchedAt: TRANSIT_EPOCH, vehicles: [] };
else if (url.pathname.startsWith('/api/transit/trail/')) {
const vehicleId = decodeURIComponent(url.pathname.split('/').at(-1));
const record = transitSnapshot().vehicles.find(
(row) => row.id === vehicleId,
);
body = {
version: 1,
feedId: 'mbta',
vehicleId,
truncated: false,
epochs: [
{ id: 1, trip: record.tripId, route: record.routeId, mode: 'bus' },
],
fixes: Array.from({ length: 9 }, (_, i) => {
const snapshot = transitSnapshot(i),
row = snapshot.vehicles.find((v) => v.id === vehicleId);
return [snapshot.fetchedAt, row.lat, row.lon, 0, 1];
}),
};
} else body = { states: [], ac: [], vessels: [], feeds: [] };
await session.send('Fetch.fulfillRequest', {
requestId: event.requestId,
responseCode: 200,
responseHeaders: [{ name: 'Content-Type', value: 'application/json' }],
body: Buffer.from(JSON.stringify(body)).toString('base64'),
});
} catch (error) {
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
}
});
return {
requests,
setStep(value) {
step = value;
},
async dispose() {
disposed = true;
await session.detach();
},
};
}
+97 -267
View File
@@ -347,6 +347,7 @@
"src/layers/flights/tracking.js",
"src/layers/flights/rendering.js",
"src/layers/flights/testing.js",
"src/layers/flights/hiddenContactRotation.test.mjs",
"src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/queries.js",
@@ -368,10 +369,8 @@
"src/layers/military/index.js",
"src/layers/aircraft/index.js",
"src/layers/aircraft/classification.js",
"src/layers/aircraft/tracking.js",
"src/layers/military/ownership.test.mjs",
"src/layers/aircraft/classification.test.mjs",
"src/layers/aircraft/tracking.test.mjs",
"src/data/aisLiveVessels.js",
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
@@ -476,14 +475,18 @@
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
"src/layers/installations/source.js",
"src/layers/installations/state.js",
"src/layers/installations/policy.js",
"src/layers/installations/rendering.js",
"src/layers/installations/ingestion.js",
"src/layers/installations/index.js",
"src/layers/installations/model.js",
"src/layers/installations/selection.js",
"src/layers/awareness/focus.js",
"src/layers/awareness/lifecycle.js",
"src/layers/awareness/dependencies.js",
@@ -519,17 +522,20 @@
"src/layers/traffic/model.js",
"src/layers/traffic/timing.js",
"src/layers/traffic/style.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/viewport.js",
"src/layers/bikeshare/registry.js",
"src/layers/bikeshare/controls.js",
"src/layers/bikeshare/source.js",
"src/layers/bikeshare/state.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/rendering.js",
"src/layers/bikeshare/testing.js",
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/index.js",
"src/layers/bikeshare/model.js",
"src/layers/bikeshare/selection.js",
"src/data/traffic.js",
"src/data/bikeshare.js",
"src/layers/traffic/source.test.mjs",
@@ -590,9 +596,9 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/overlay.test.mjs",
"src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs",
@@ -782,6 +788,7 @@
"server/providers/transit.js",
"server/providers/transitHistory.js",
"server/providers/transitHistory.test.mjs",
"src/data/rioRouteNames.js",
"src/data/transitFeeds.js",
"src/data/transitFeeds.test.mjs",
"src/data/transitProxy.js",
@@ -1061,7 +1068,6 @@
"src/layers/traffic/roadModes.test.mjs",
"scripts/qa-traffic-motion.mjs",
"src/layers/traffic/motion.test.mjs",
"src/layers/traffic/handoff.test.mjs",
"scripts/qa-journey-recorder.mjs",
"scripts/qa-alpr-journey.mjs",
"scripts/qa-context-arrivals.mjs",
@@ -1123,6 +1129,8 @@
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs",
"src/cameraTilt.test.mjs",
"src/ui/shellFacade.test.mjs",
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
@@ -1208,268 +1216,6 @@
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/layers/flights/render/cesium.js",
"src/layers/flights/render/navara.js",
"src/layers/aircraft/render/navara.test.mjs",
"src/layers/aircraft/render/navara.js",
"src/layers/flights/renderer.test.mjs",
"src/layers/military/renderer.test.mjs",
"src/layers/military/policy.test.mjs",
"src/testSupport/aircraftRecordingRenderer.mjs",
"src/layers/aircraft/render/cesium.test.mjs",
"src/layers/aircraft/render/cesium.js",
"src/layers/aircraft/render/contract.js",
"src/layers/shared/color.test.mjs",
"src/layers/shared/color.js",
"src/layers/shared/viewRectangle.js",
"src/layers/military/render/cesium.js",
"src/layers/military/render/navara.js",
"src/layers/aircraft/qaDrawn.js",
"src/app/navaraLayers.test.mjs",
"src/layers/vessels/icons.js",
"src/layers/vessels/render/cesium.js",
"src/layers/vessels/render/contract.js",
"src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/testFixtures.mjs",
"src/layers/vessels/render/navara.js",
"src/layers/shared/render/engineView.js",
"src/layers/shared/render/navaraGroundEffect.js",
"src/layers/shared/render/navaraMoverBank.js",
"src/layers/shared/render/imageAtlas.js",
"src/layers/installations/render/cesium/lifecycle.js",
"src/layers/installations/render/cesium/rendering.js",
"src/layers/installations/render/cesium/selection.js",
"src/layers/bikeshare/render/cesium/lifecycle.js",
"src/layers/bikeshare/presentation.js",
"src/layers/bikeshare/stationPoints.js",
"src/layers/bikeshare/presentation.test.mjs",
"src/layers/bikeshare/selectionModel.js",
"src/layers/bikeshare/render/cesium.js",
"src/layers/bikeshare/render/cesium/viewport.js",
"src/layers/bikeshare/render/cesium/lifecycle.test.mjs",
"src/layers/bikeshare/render/navara.js",
"src/layers/bikeshare/render/navara.test.mjs",
"src/layers/bikeshare/render/cesium/rendering.js",
"src/layers/bikeshare/render/cesium/selection.js",
"src/layers/cctv/projectionScheduling.test.mjs",
"src/layers/cctv/gizmo.test.mjs",
"src/layers/cctv/render/cesiumGizmo.js",
"src/layers/cctv/render/navara.js",
"src/layers/cctv/render/navara.test.mjs",
"src/layers/cctv/render/navaraMeshes.js",
"src/layers/cctv/render/navaraMeshes.test.mjs",
"src/layers/cctv/render/navaraGizmo.js",
"src/layers/cctv/render/navaraGizmo.test.mjs",
"src/layers/cctv/render/navaraLine.js",
"src/layers/cctv/render/cesium.js",
"src/layers/cctv/render/cesiumViewshed.js",
"src/layers/cctv/render/cesium.test.mjs",
"src/layers/cctv/lifecycle.test.mjs",
"src/layers/cctv/projectionMedia.test.mjs",
"src/layers/cctv/ingestion.test.mjs",
"src/layers/alpr/render/cesiumPresentation.js",
"src/layers/alpr/render/cesium.js",
"src/layers/alpr/render/navara.js",
"src/layers/alpr/render/navara.test.mjs",
"src/layers/alpr/render/navaraOverlay.js",
"src/layers/alpr/render/navaraOverlay.test.mjs",
"scripts/fixtures/w2/alpr.mjs",
"scripts/fixtures/w2/cctv.mjs",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/radio/render/cesium.js",
"src/layers/radio/render/navara.js",
"src/layers/radio/render/navara.test.mjs",
"src/layers/radio/screenClusters.js",
"src/layers/radio/screenClusters.test.mjs",
"src/maps/sceneApply.js",
"src/maps/sceneApply.test.mjs",
"src/globe/contract.test.mjs",
"src/globe/testing/fakeEngine.test.mjs",
"src/globe/cesium/events.test.mjs",
"src/globe/cesium/camera.test.mjs",
"src/globe/cesium/projector.test.mjs",
"src/globe/cesium/picker.test.mjs",
"src/globe/cesium/frames.test.mjs",
"src/globe/cesium/render.test.mjs",
"src/globe/cesium/surface.test.mjs",
"src/globe/cesium/input.test.mjs",
"src/globe/cesium/credits.test.mjs",
"src/globe/cesium/maps.test.mjs",
"src/globe/cesium/createCesiumEngine.test.mjs",
"src/globe/cesium/engineFromViewer.js",
"src/globe/cesium/engineFromViewer.test.mjs",
"src/globe/cesium/testing/fakeViewer.mjs",
"src/globe/cesium/testing/cameraViewer.mjs",
"src/app/globeEngine.js",
"src/app/globeEngine.test.mjs",
"src/layers/transit/render/cesium.js",
"src/layers/transit/render/cesium.test.mjs",
"src/layers/transit/render/navara.js",
"src/layers/transit/render/navaraView.js",
"src/layers/transit/render/navaraSprites.js",
"src/layers/transit/render/navaraTrail.js",
"src/layers/transit/render/navara.test.mjs",
"scripts/fixtures/w2/transit.mjs",
"src/layers/localAdsb/render/cesium.js",
"src/layers/localAdsb/render/navara.js",
"src/layers/localAdsb/renderer.test.mjs",
"build/navaraAssets.js",
"scripts/qa-engine-switch.mjs",
"scripts/qa/tileCache.mjs",
"src/annotations/resolverEngine.test.mjs",
"src/voice/gevActionsEngine.test.mjs",
"src/app/engineChoice.js",
"src/app/engineChoice.test.mjs",
"src/app/navaraMode.js",
"src/app/navaraScene.test.mjs",
"src/app/viewerlessTools.test.mjs",
"src/cameraAngles.js",
"src/cameraAngles.test.mjs",
"src/data/cameraArrival.test.mjs",
"src/data/cameraSensitivity.test.mjs",
"src/globe/cesium/camera.js",
"src/globe/cesium/convert.js",
"src/globe/cesium/createCesiumEngine.js",
"src/globe/cesium/credits.js",
"src/globe/cesium/events.js",
"src/globe/cesium/frames.js",
"src/globe/cesium/input.js",
"src/globe/cesium/maps.js",
"src/globe/cesium/picker.js",
"src/globe/cesium/projector.js",
"src/globe/cesium/render.js",
"src/globe/cesium/surface.js",
"src/globe/contract.js",
"src/globe/cameraMath.js",
"src/globe/cameraMath.test.mjs",
"src/globe/navara/cameraModel.js",
"src/globe/navara/cameraModel.test.mjs",
"src/globe/navara/createNavaraEngine.js",
"src/globe/navara/createNavaraEngine.test.mjs",
"src/globe/navara/credits.js",
"src/globe/navara/credits.test.mjs",
"src/globe/navara/groundFloor.js",
"src/globe/navara/groundFloor.test.mjs",
"src/globe/navara/index.js",
"src/globe/navara/input.js",
"src/globe/navara/input.test.mjs",
"src/globe/navara/internals.js",
"src/globe/navara/internals.test.mjs",
"src/globe/navara/maps.js",
"src/globe/navara/maps.test.mjs",
"src/globe/navara/sharpen.js",
"src/globe/navara/surface.js",
"src/globe/navara/surface.test.mjs",
"src/globe/navara/surfaceRay.test.mjs",
"src/globe/navara/viewOptions.js",
"src/globe/navara/viewOptions.test.mjs",
"src/globe/testing/contractSuite.mjs",
"src/globe/testing/fakeEngine.js",
"src/mapStackController.test.mjs",
"src/orbit.test.mjs",
"src/services/groundSnap.test.mjs",
"src/sharelinkPose.test.mjs",
"src/tooling/navaraAssets.test.mjs",
"src/tooling/tileCache.test.mjs",
"scripts/check-main-chunk.mjs",
"src/tooling/mainChunkComposition.test.mjs",
"src/tooling/initialChunkGuard.test.mjs",
"src/globe/movers.test.mjs",
"src/globe/navara/movers/pack.test.mjs",
"src/globe/navara/movers/pickIds.test.mjs",
"src/globe/navara/movers/moverMesh.test.mjs",
"src/globe/navara/movers/modelMover.test.mjs",
"src/bench/syntheticFlights.test.mjs",
"src/globe/lines.test.mjs",
"src/globe/navara/lines/lineMesh.test.mjs",
"src/globe/pickDelivery.test.mjs",
"src/globe/navara/picking.test.mjs",
"src/data/selectionDispatcher.test.mjs",
"src/globe/navara/cascadedLightCopy.test.mjs",
"src/tooling/navaraDependencies.test.mjs",
"build/navaraDependencies.js",
"build/initialChunkGuard.js",
"src/globe/follow.js",
"src/globe/follow.test.mjs",
"src/globe/cesium/follow.js",
"src/globe/cesium/follow.test.mjs",
"src/globe/surfaceDetail.js",
"src/globe/surfaceDetail.test.mjs",
"src/globe/navara/followGestures.js",
"src/globe/navara/followGestures.test.mjs",
"src/globe/cpuPicks.test.mjs",
"src/app/layers/directory.js",
"src/app/layers/renderers.js",
"src/app/layers/renderers.test.mjs",
"src/data/lifecycleEngine.test.mjs",
"src/ui/shareRestorationEngine.test.mjs",
"src/app/qaProbe.js",
"src/app/qaProbe.test.mjs",
"src/app/layers/qaSources.js",
"src/app/layers/qaSources.test.mjs",
"src/bench/w1Feeds.js",
"src/bench/satelliteFixture.js",
"src/bench/satelliteFixture.test.mjs",
"src/layers/satellites/render/cesium.js",
"src/celestialRing/render/cesium.js",
"src/celestialRing/view.js",
"src/layers/satellites/render/navara.js",
"src/layers/satellites/render/navara.test.mjs",
"src/layers/satellites/render/cesium.test.mjs",
"src/geo/ellipsoidGeodesic.test.mjs",
"src/bench/launchFixture.js",
"src/layers/launches/replayClock.test.mjs",
"src/geo/boundingSphere.test.mjs",
"src/layers/launches/render/cesium.js",
"src/layers/launches/launchPadFrame.test.mjs",
"src/layers/launches/render/navara.js",
"src/layers/launches/render/navara.test.mjs",
"src/layers/satellites/interaction.test.mjs",
"src/layers/launches/lifecycleSnapshot.test.mjs",
"src/data/render/cesiumProjection.js",
"src/data/render/cesiumTrailRenderer.js",
"src/data/render/cesiumContactTrailRenderer.js",
"src/layers/flights/trackingRefresh.test.mjs",
"scripts/qa-heading-navara.mjs",
"src/layers/aircraft/qaDrawn.test.mjs",
"src/data/render/cesiumConstants.js",
"src/globe/navara/movers/frameCamera.js",
"src/globe/navara/overlayPass.js",
"src/globe/navara/overlayPass.test.mjs",
"src/globe/navara/movers/modelTint.js",
"src/globe/navara/movers/frameCamera.test.mjs",
"src/layers/localAdsb/qaDrawn.js",
"src/layers/localAdsb/qaDrawn.test.mjs",
"src/tooling/w1RuntimeEvidence.test.mjs",
"scripts/fixtures/w2/earthquakes.mjs",
"src/layers/earthquakes/render/cesium.js",
"src/layers/earthquakes/render/cesium.test.mjs",
"src/layers/earthquakes/render/navara.js",
"src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGeometry.test.mjs",
"src/layers/earthquakes/render/navaraGrid.js",
"src/layers/earthquakes/render/navaraGrid.test.mjs",
"src/layers/earthquakes/render/navara.test.mjs",
"src/layers/firms/render/cesium.js",
"src/layers/firms/render/cesium.test.mjs",
"src/layers/firms/rectangle.js",
"src/layers/firms/rectangle.test.mjs",
"src/layers/firms/viewport.test.mjs",
"scripts/fixtures/w2/firms.mjs",
"src/layers/firms/render/navara.test.mjs",
"src/layers/firms/render/navaraGeometry.test.mjs",
"src/layers/firms/render/navaraSprites.test.mjs",
"src/layers/firms/lifecycle.test.mjs",
"scripts/fixtures/w2/radio.mjs",
"scripts/qa-map-policy.mjs",
"scripts/qa-map-policy.test.mjs",
"src/layers/recentImagery/render/tiles.test.mjs",
"src/layers/recentImagery/render/navara.test.mjs",
"src/ui/imageryBoxNavara.test.mjs",
"scripts/qa-w2-imagery.mjs",
"src/globe/navara/postSamplingPass.test.mjs",
"src/globe/navara/emptySceneRender.test.mjs",
"src/layers/installations/render/navaraStencilBatch.test.mjs",
"src/data/firmsIntensity.js",
"src/data/firmsIntensity.test.mjs",
"src/ui/layerPanel/RowSwitch.vue",
@@ -1526,5 +1272,89 @@
"src/voice/geminiTextChat.js",
"src/voice/panelLayout.js",
"src/voice/textChat.js",
"src/voice/textProtocol.js"
"src/voice/textProtocol.js",
"src/app/sourceComposition.js",
"src/app/sourceComposition.test.mjs",
"src/data/dataCredits.js",
"src/data/layerState.js",
"src/data/layerState.test.mjs",
"src/data/spriteOrder.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/i18n/same-as-english.json",
"src/navigationPolicy.js",
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayDraw.test.mjs",
"src/sources/rateLimit.js",
"src/ui/layerNames.js",
"src/ui/styles/cyber.css",
"src/voice/layerManifest.js",
"src/worldFocus.js",
"src/worldFocus.test.mjs",
"server/providers/acartia.js",
"server/providers/acartia.test.mjs",
"server/providers/common/userAgent.js",
"server/providers/inaturalist/client.js",
"server/providers/inaturalist/observations.js",
"server/providers/inaturalist/observations.test.mjs",
"src/app/layers/wildlifeSightings.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/cards.test.mjs",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/docs.test.mjs",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/model.test.mjs",
"src/layers/wildlifeSightings/ownership.test.mjs",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/photos.test.mjs",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/records.test.mjs",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/source.test.mjs",
"src/layers/wildlifeSightings/viewport.js",
"src/layers/wildlifeSightings/viewport.test.mjs",
"src/sources/rateLimit.test.mjs",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/acquisition.test.mjs",
"server/providers/inaturalist/client.test.mjs",
"src/layers/wildlifeSightings/acartiaTaxa.test.mjs",
"src/ui/wildlifeSightingCard.test.mjs",
"src/overlays/wildlifeSightingOverlay.test.mjs",
"scripts/run-perf-tests.mjs",
"src/perfTestRunner.test.mjs",
"src/testSupport/wildlifeBillboardModel.mjs",
"src/layers/wildlifeSightings/gridViewport.test.mjs",
"src/layers/wildlifeSightings/rendering.test.mjs",
"src/layers/wildlifeSightings/sprites.test.mjs",
"src/layers/wildlifeSightings/performance.test.mjs",
"src/layers/wildlifeSightings/model.perf.test.mjs",
"src/ui/wildlifeSightingPlacement.js",
"server/providers/inaturalist/queue.js",
"server/providers/inaturalist/queue.test.mjs",
"server/providers/inaturalist/grid.js",
"server/providers/inaturalist/grid.test.mjs",
"server/providers/inaturalist/grid.fixture.mjs",
"src/layers/wildlifeSightings/performance.js",
"src/layers/wildlifeSightings/renderPerformanceFixtures.mjs",
"server/providers/inaturalist/speed.test.mjs",
"src/layers/wildlifeSightings/render.perf.test.mjs",
"src/layers/wildlifeSightings/cache.integration.test.mjs",
"src/layers/wildlifeSightings/density.test.mjs",
"src/layers/wildlifeSightings/selection.test.mjs",
"server/providers/inaturalist/proxy.fixture.mjs"
]
-6
View File
@@ -1,6 +0,0 @@
{
"gzipBytes": 2021566,
"allowanceBytes": 20216,
"measuredAt": "merge of origin/main ac28a11e, keyless build (GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN empty), entry index-ChmLBAMR.js, 6 220 779 bytes, gzip level 9; main itself grew 93 873 bytes gzip-9 from the merge base (1 853 283 to 1 947 156)",
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
}
+3 -13
View File
@@ -15,13 +15,9 @@ const browserGlobals = new Set([
'getComputedStyle',
]);
/**
* Inspect literal module dependencies and browser-platform references without
* executing code. With `dynamic`, also list the import() specifiers apart.
*/
export function analyzeModule(source, { dynamic = false } = {}) {
/** Inspect literal module dependencies and browser-platform references without executing code. */
export function analyzeModule(source) {
const imports = [];
const dynamicImports = [];
const browser = new Set();
function walk(node, parent, field) {
if (!node || typeof node !== 'object') return;
@@ -39,8 +35,6 @@ export function analyzeModule(source, { dynamic = false } = {}) {
'Computed module imports are not allowed in runtime code',
);
imports.push(node.source.value);
if (node.type === 'ImportExpression')
dynamicImports.push(node.source.value);
}
if (node.type === 'CallExpression' && node.callee?.name === 'require')
throw new Error('Runtime modules must use ES imports');
@@ -77,11 +71,7 @@ export function analyzeModule(source, { dynamic = false } = {}) {
}
}
walk(parsers.babel.parse(source));
return {
imports,
browser: [...browser],
...(dynamic && { dynamicImports }),
};
return { imports, browser: [...browser] };
}
/** Return static imports, literal dynamic imports and re-exports. */
File diff suppressed because it is too large. Load diff
-614
View File
@@ -1,614 +0,0 @@
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
import {
createStrict,
ENGINES,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
// The strict acceptance: every browser scenario against one keyless local
// preview, under the strict network policy (scripts/qa-strict-harness.mjs),
// writing <output>/acceptance.json, one log and one assertion manifest per
// scenario.
//
// node scripts/qa-all-layer-switches.mjs http://127.0.0.1:4208 <output>
// [--serve[=<commit>]] build <commit> (default HEAD) keyless in a clean
// worktree and serve it with every .env key blank
// [--scratch=DIR] where --serve puts its checkout and build
// [--only=a,b] run only these labels
// [--quick] the soak with 2 runs of 10 switches and 100
// switches for the long run
//
// The run fails when a scenario fails, when any catalog layer (read from
// the live page and cross-checked with src/app/layers/directory.js) has no
// assertion on either engine, or when any network policy violation shows.
const base = localPreview(process.argv[2]);
const output = process.argv[3]?.startsWith('--')
? mkdtempSync(join(tmpdir(), 'gev-acceptance-'))
: resolve(process.argv[3] ?? mkdtempSync(join(tmpdir(), 'gev-acceptance-')));
const flag = (name) =>
process.argv
.find((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`))
?.split('=')[1] ?? (process.argv.includes(`--${name}`) ? true : null);
const only = flag('only') ? String(flag('only')).split(',') : null;
const quick = Boolean(flag('quick'));
mkdirSync(join(output, 'evidence'), { recursive: true });
const root = fileURLToPath(new URL('..', import.meta.url));
const git = (...args) =>
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
const dirty = () =>
git('status', '--porcelain')
.split('\n')
.filter(Boolean)
.map((line) => line.slice(3));
// Fail once, with the fix, instead of once per scenario.
let chrome;
try {
const browser = await puppeteer.launch({ headless: true });
chrome = await browser.version();
await browser.close();
} catch (error) {
console.error(
'Puppeteer cannot start Chrome: ' +
String(error?.message)
.split('\n')[0]
.replace(/ This can occur.*/, '') +
'\nPoint PUPPETEER_EXECUTABLE_PATH at an installed Chrome, for example\n' +
' PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n' +
'or download the pinned build with `npx puppeteer browsers install chrome`.',
);
process.exit(2);
}
// Every name the checkout's .env files define, plus key-like names in this
// environment: all blank for the build and the preview.
const KEY_LIKE = /(_KEY|_TOKEN|_SECRET|_CLIENT_ID|PASSWORD)$/;
/** The [name, value] pairs of the checkout's .env and .env.local, in order. */
const dotenv = () =>
['.env', '.env.local']
.filter((file) => existsSync(join(root, file)))
.flatMap((file) => readFileSync(join(root, file), 'utf8').split('\n'))
.map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)/))
.filter(Boolean)
.map(([, name, raw]) => [name, raw.trim().replace(/^["']|["']$/g, '')]);
const dotenvNames = () => [...new Set(dotenv().map(([name]) => name))].sort();
const envNames = () =>
[
...new Set([
...dotenvNames(),
...Object.keys(process.env).filter((name) => KEY_LIKE.test(name)),
]),
].sort();
// Credentials only (model names and URLs are settings the code may also
// carry as defaults).
const envValues = () =>
dotenv()
.filter(([name, value]) => KEY_LIKE.test(name) && value.length >= 8)
.map(([, value]) => value);
const scrubbed = () => ({
...process.env,
...Object.fromEntries(envNames().map((name) => [name, ''])),
});
// The report names no local directory: the output, the served checkout and
// this repository become placeholders (logs carry stack traces).
const machinePaths = (text) => {
let out = String(text);
for (const [path, name] of [
[served?.scratch, '<scratch>'],
[output, '<output>'],
[root.replace(/\/$/, ''), '<repo>'],
])
if (path) out = out.split(path).join(name);
return out;
};
let served = null;
let preview = null;
const cleanup = () => {
if (preview && !preview.killed) preview.kill('SIGTERM');
if (served?.worktree)
try {
git('worktree', 'remove', '--force', served.worktree);
served.worktree = null;
} catch (error) {
console.error('Could not remove', served.worktree, error.message);
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => process.exit(130));
if (flag('serve')) {
const commit = git(
'rev-parse',
flag('serve') === true ? 'HEAD' : String(flag('serve')),
).trim();
// --scratch=DIR keeps the checkout and build out of the system temp.
const scratchRoot = flag('scratch')
? resolve(String(flag('scratch')))
: tmpdir();
mkdirSync(scratchRoot, { recursive: true });
const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-'));
const worktree = join(scratch, 'checkout');
const dist = join(scratch, 'dist');
git('worktree', 'add', '--detach', worktree, commit);
symlinkSync(join(root, 'node_modules'), join(worktree, 'node_modules'));
served = {
scratch,
commit,
worktree,
dist,
// Names from .env files only; other key-like variables of this shell
// are blanked too but only counted.
blankedEnv: dotenvNames(),
otherKeyLikeVariablesBlanked: envNames().filter(
(name) => !dotenvNames().includes(name),
).length,
envFilesInCheckout: ['.env', '.env.local'].filter((f) =>
existsSync(join(worktree, f)),
),
};
console.log('Building', commit, 'keyless into', dist);
const build = spawnSync(
process.execPath,
[join(root, 'node_modules/vite/bin/vite.js'), 'build', '--outDir', dist],
{ cwd: worktree, env: scrubbed(), encoding: 'utf8', maxBuffer: 1 << 26 },
);
if (build.status !== 0) {
console.error(build.stdout + build.stderr);
process.exit(2);
}
// No .env credential value may reach the built files.
const values = envValues();
let leaks = 0;
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const file = join(dir, name);
if (statSync(file).isDirectory()) walk(file);
else if (/\.(js|html|json|css|webmanifest)$/.test(name)) {
const text = readFileSync(file, 'utf8');
for (const value of values) if (text.includes(value)) leaks++;
}
}
};
walk(dist);
served.distCredentialValuesFound = leaks;
served.mainChunk = JSON.parse(
spawnSync(
process.execPath,
[join(root, 'scripts/check-main-chunk.mjs'), `--dist=${dist}`, '--json'],
{ cwd: root, encoding: 'utf8' },
).stdout || 'null',
);
preview = spawn(
process.execPath,
[
join(root, 'node_modules/vite/bin/vite.js'),
'preview',
'--outDir',
dist,
'--host',
base.hostname,
'--port',
base.port,
'--strictPort',
],
{ cwd: worktree, env: scrubbed(), stdio: 'ignore' },
);
for (let i = 0; i < 60; i++) {
try {
if ((await fetch(base.origin + '/')).ok) break;
} catch {
// Not up yet.
}
await new Promise((r) => setTimeout(r, 500));
}
}
try {
const response = await fetch(base.origin + '/', {
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw Error('HTTP ' + response.status);
} catch (error) {
console.error(
'No preview answers at ' + base.origin + ' (' + error.message + ').',
);
process.exit(2);
}
// The preview reports no paid key: its own keyless status routes.
const probe = async (path) => {
try {
const r = await fetch(base.origin + path, {
headers: { 'Sec-Fetch-Site': 'same-origin' },
});
return { status: r.status, body: await r.json().catch(() => null) };
} catch (error) {
return { error: error.message };
}
};
const keys = {
openaiHudSummary: await probe('/api/openai/hud-summary/status'),
firms: await probe('/api/firms/status'),
tomtom: await probe('/api/tomtom/status'),
mapillary: await probe('/api/mapillary/status'),
};
const paidKeysConfigured = [
keys.openaiHudSummary.body?.configured !== false && 'OpenAI',
keys.firms.body?.hasKey !== false && 'FIRMS',
keys.tomtom.body?.hasKey !== false && 'TomTom',
keys.mapillary.body?.configured !== false && 'Mapillary',
].filter(Boolean);
const dirtyFiles = dirty();
const soakRuns = quick
? ['--runs=2', '--swaps=10']
: ['--runs=6', '--swaps=50'];
const longRun = quick ? '--swaps=100' : '--swaps=500';
// label, script, layers, extra arguments, timeout in seconds.
const runs = [
['engine-switch', 'engine-switch', ['earthquakes']],
['director-engine-switch', 'director-engine-switch', []],
['layer-handoff', 'layer-handoff', ['flights']],
['layer-handoff--military', 'layer-handoff', ['military'], ['--military']],
[
'ground-layer-switch',
'ground-layer-switch',
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
],
[
'contact-switch',
'contact-switch',
['military-awareness', 'military-installations'],
],
['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']],
['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']],
[
'open-layers-switch',
'open-layers-switch',
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
],
[
'weather-engine-switch',
'weather-engine-switch',
[
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
],
],
['wind-engine-switch', 'wind-engine-switch', ['wind']],
[
'infrastructure-engine-switch',
'infrastructure-engine-switch',
['local-dams', 'local-datacenters'],
],
[
'submarine-cables-engine-switch',
'submarine-cables-engine-switch',
['telegeography-submarine-cables'],
],
['directions-engine-switch', 'directions-engine-switch', ['directions']],
['nepal-locator-switch', 'nepal-locator-switch', ['bhote-koshi-locator']],
['nepal-event-switch', 'nepal-event-switch', ['bhote-koshi-2026']],
['traffic-engine-switch', 'traffic-engine-switch', ['traffic']],
[
'traffic-engine-switch--simulation',
'traffic-engine-switch',
['traffic'],
['--simulation'],
],
[
'street-level-engine-switch',
'street-level-engine-switch',
['street-level'],
],
[
'street-level-engine-switch--panoramax',
'street-level-engine-switch',
['street-level'],
['--panoramax'],
],
['radio-engine-switch', 'radio-engine-switch', ['radio']],
['w2-imagery', 'w2-imagery', ['recent-imagery']],
['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']],
[
'fire-camera-switch--fires',
'fire-camera-switch',
['local-firms'],
['--fires'],
],
['urban-engine-switch', 'urban-engine-switch', ['bikeshare']],
[
'urban-engine-switch--transit',
'urban-engine-switch',
['transit'],
['--transit'],
],
['cctv-engine-switch', 'cctv-engine-switch', ['cctv']],
['space-engine-switch', 'space-engine-switch', ['satellites']],
[
'space-engine-switch--missions',
'space-engine-switch',
['rocket-launches'],
['--missions'],
],
// One application per page, and voice across a switch: these block paid
// routes themselves but do not run under the strict harness, so they add
// no assertion manifest and no network ledger.
['app-once', 'app-once', [], [], 600, true],
['voice-engine-switch', 'voice-engine-switch', [], [], 300, true],
['strict-rapid-switch', 'strict-rapid-switch', []],
['strict-hidden-switch', 'strict-hidden-switch', []],
['strict-context-loss', 'strict-context-loss', []],
['strict-parked-release', 'strict-parked-release', []],
['strict-navara-hd', 'strict-navara-hd', []],
['strict-failure-matrix', 'strict-failure-matrix', [], [], 600],
['strict-cold-chunks', 'strict-cold-chunks', []],
['strict-prod-smoke', 'strict-prod-smoke', []],
// The app's visual styles, bloom, contact sonar, cockpit clouds, and the
// annotations, Draw, Import and the scope mask with the celestial ring on
// Navara (those are tools and display overlays, not catalog layers:
// TOOL_IDS).
['navara-visual-styles', 'navara-visual-styles', []],
['navara-bloom', 'navara-bloom', []],
['navara-sonar', 'navara-sonar', []],
['navara-cockpit-clouds', 'navara-cockpit-clouds', []],
['navara-annotations', 'navara-annotations', ['annotations']],
['navara-draw', 'navara-draw', ['draw']],
['navara-geo-import', 'navara-geo-import', ['geo-import']],
['navara-scope', 'navara-scope', ['scope-mask', 'celestial-ring']],
['strict-soak', 'strict-soak', [], soakRuns, 1200],
[
'strict-soak--long',
'strict-soak',
[],
['--runs=1', longRun, '--assert'],
1800,
],
];
// Ids a scenario covers that are tools, not catalog layers: their
// assertions are recorded, but the catalog comparison leaves them out.
const TOOL_IDS = new Set([
'annotations',
'draw',
'geo-import',
'scope-mask',
'celestial-ring',
]);
const scenarioIds = [
...new Set(
runs.flatMap(([, , ids]) => ids).filter((id) => !TOOL_IDS.has(id)),
),
].sort();
const directoryIds = APPLICATION_LAYER_DIRECTORY.map((l) => l.id).sort();
// The catalog as the live page has it, on both engines.
const liveCatalog = {};
{
const strict = createStrict({
origin: base.origin,
layers: [],
name: 'catalog',
});
const browser = await strict.launch();
try {
for (const engine of ENGINES) {
const page = await browser.newPage();
await page.goto(
`${base.origin}/?engine=${engine}&welcome=0#v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=osm&l=`,
);
await waitForEngine(page, engine);
liveCatalog[engine] = await page.evaluate(() =>
[...window.__godsEyeView.dataManager.layers.keys()].sort(),
);
await page.close();
}
} finally {
await browser.close();
}
liveCatalog.violations = strict.ledger.violations;
}
const scenarios = [];
const report = {
commit: git('rev-parse', 'HEAD').trim(),
dirty: dirtyFiles.length > 0,
dirtyFiles,
dirtyFilesAtEnd: null,
chrome,
puppeteerExecutable: process.env.PUPPETEER_EXECUTABLE_PATH || null,
node: process.version,
platform: process.platform + ' ' + process.arch,
baseUrl: base.origin,
preview: served
? {
servedByRunner: true,
appCommit: served.commit,
appCheckoutClean: true,
scenariosFrom: 'the same clean checkout',
envFilesInCheckout: served.envFilesInCheckout,
blankedEnv: served.blankedEnv,
otherKeyLikeVariablesBlanked: served.otherKeyLikeVariablesBlanked,
distCredentialValuesFound: served.distCredentialValuesFound,
}
: { servedByRunner: false },
keyProbes: keys,
paidKeysConfigured,
mainChunk: served?.mainChunk ?? null,
networkPolicy: {
blockedFirst:
'Google (*.googleapis.com, *.google.com, maps.google.*, *.gstatic.com), Cesium ion (*.cesium.com), preview /api/openai/*, /api/realtime/*, /api/google/*',
allowed:
'the preview origin, each scenario’s declared fixture URLs, keyless probes answered by the harness, and the free tile hosts answered from the disk cache',
keylessProbes: (await import('./qa-map-policy.mjs')).KEYLESS_PROBES.map(
(p) => p.pattern,
),
tileHosts: (await import('./qa-map-policy.mjs')).STRICT_TILE_HOSTS,
},
catalog: {
directory: directoryIds,
live: liveCatalog,
scenarios: scenarioIds,
},
startedAt: new Date().toISOString(),
finishedAt: null,
passed: 0,
failed: 0,
networkViolations: 0,
coverage: null,
uncovered: null,
scenarios,
};
const save = () =>
writeFileSync(
join(output, 'acceptance.json'),
machinePaths(JSON.stringify(report, null, 2)) + '\n',
);
save();
for (const [label, name, layers, extra = [], seconds = 300, external] of runs) {
if (only && !only.includes(label)) continue;
const script = 'scripts/qa-' + name + '.mjs';
const evidence = join(output, 'evidence', label + '.json');
rmSync(evidence, { force: true });
const args = [base.origin, ...extra];
if (external) args.splice(0, 1, base.origin + '/');
else args.push(`--layers=${layers.join(',')}`, `--evidence=${evidence}`);
const start = Date.now();
// With --serve the scenarios run from the same clean checkout the app was
// built from, so other work in this tree cannot change them.
const scripts = served?.worktree ?? root;
const run = spawnSync(process.execPath, [join(scripts, script), ...args], {
encoding: 'utf8',
timeout: seconds * 1000,
maxBuffer: 1 << 26,
env: scrubbed(),
});
const status =
run.status === 0
? 'pass'
: run.error?.code === 'ETIMEDOUT'
? 'timeout'
: 'fail';
const log = machinePaths(
(run.stdout || '') + (run.stderr || '') + (run.error?.message || ''),
);
writeFileSync(join(output, label + '.log'), log);
const manifest = existsSync(evidence)
? JSON.parse(machinePaths(readFileSync(evidence, 'utf8')))
: null;
if (manifest)
writeFileSync(evidence, JSON.stringify(manifest, null, 2) + '\n');
scenarios.push({
label,
script,
args: args.map((arg) =>
arg.includes(output) ? arg.replace(output, '<output>') : arg,
),
layers,
external: Boolean(external),
status,
exitCode: run.status,
seconds: Math.round((Date.now() - start) / 100) / 10,
log: label + '.log',
logSha256: createHash('sha256').update(log).digest('hex'),
evidence: manifest ? 'evidence/' + label + '.json' : null,
assertions: manifest?.assertions.length ?? 0,
coverage: manifest?.coverage ?? null,
picks: manifest?.picks ?? [],
network: manifest
? {
requests: manifest.network.requests,
violations: manifest.network.violations,
external: manifest.network.external,
tileCache: manifest.network.tileCache,
probes: manifest.network.probes,
routes: manifest.network.routes,
previewApi: manifest.network.previewApi,
fixtures: manifest.network.fixtures,
}
: null,
contracts: manifest?.contracts ?? null,
error: manifest?.error ?? null,
});
if (manifest?.soak) scenarios.at(-1).soak = manifest.soak;
report.passed = scenarios.filter((row) => row.status === 'pass').length;
report.failed = scenarios.length - report.passed;
report.networkViolations =
scenarios.reduce(
(sum, row) => sum + (row.network?.violations.length ?? 0),
0,
) + liveCatalog.violations.length;
save();
console.log(status.toUpperCase(), label, scenarios.at(-1).seconds);
}
// Coverage that cannot be faked: every catalog id needs an assertion on each
// engine from some scenario's manifest.
const catalogIds = [
...new Set([...directoryIds, ...(liveCatalog.navara ?? [])]),
].sort();
report.coverage = Object.fromEntries(
catalogIds.map((id) => [
id,
Object.fromEntries(
ENGINES.map((engine) => [
engine,
scenarios.reduce(
(sum, row) => sum + (row.coverage?.[id]?.[engine] ?? 0),
0,
),
]),
),
]),
);
report.uncovered = Object.entries(report.coverage).flatMap(([id, counts]) =>
ENGINES.filter((engine) => !counts[engine]).map(
(engine) => `${id}@${engine}`,
),
);
report.catalogMatches =
JSON.stringify(liveCatalog.navara) === JSON.stringify(directoryIds) &&
JSON.stringify(liveCatalog.cesium) === JSON.stringify(directoryIds) &&
JSON.stringify(scenarioIds) === JSON.stringify(directoryIds);
report.finishedAt = new Date().toISOString();
report.dirtyFilesAtEnd = dirty();
save();
console.log(
`${catalogIds.length} catalog layers, ${report.uncovered.length} engine slots without an assertion, ` +
`${report.networkViolations} network violations, ${report.passed}/${scenarios.length} scenarios, ` +
`paid keys configured: ${paidKeysConfigured.join(', ') || 'none'}. ${join(output, 'acceptance.json')}`,
);
if (
report.failed ||
(!only && report.uncovered.length) ||
report.networkViolations ||
paidKeysConfigured.length ||
!report.catalogMatches
)
process.exitCode = 1;
// The preview child would keep this process alive: stop it and drop the
// checkout now.
cleanup();
+8 -87
View File
@@ -2,7 +2,7 @@
/**
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
*
* Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
*
* Drives the same eased camera path several times: a neighbourhood by Camp
@@ -14,7 +14,7 @@
*
* Per stop it records: time from the camera stopping to cameras shown (or an
* explicit "zoom in" state), the camera count and row status, rAF interval and
* engine render CPU time (mean / p50 / p95) while moving and while holding, and
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
* marker stability while the camera is still (camera-set changes after the stop
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
@@ -39,10 +39,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import sharp from 'sharp';
import { installAlprFixture } from './fixtures/w2/alpr.mjs';
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
import {
createScreencast,
frameStats,
@@ -63,20 +60,12 @@ const option = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
};
const appUrl = argv.find((arg) => /^https?:/.test(arg));
if (!appUrl) throw new Error('An explicit dev-server URL is required');
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
new URL(appUrl).port,
)
)
throw new Error('ALPR QA may not use a reserved port');
const appUrl =
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
const passes = option('--passes', 'off,on,on').split(',');
const assertOn = !argv.includes('--no-assert');
const dropRenders = argv.includes('--self-test-drop-renders');
if (dropRenders && new URL(appUrl).searchParams.get('engine') === 'navara')
throw new Error('The legacy render-drop self-test requires Cesium');
fs.mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const cyanCount = async (file) => {
@@ -181,8 +170,6 @@ const result = {
startedAt: new Date().toISOString(),
passes: [],
consoleErrors: [],
expectedPreviewErrors: [],
failedRequests: [],
failures: [],
};
const fail = (message) => {
@@ -192,44 +179,14 @@ const fail = (message) => {
let page;
try {
page = await browser.newPage();
await blockPaidQaMaps(page);
if (argv.includes('--fixture')) await installAlprFixture(page);
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(() =>
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
);
page.on('console', (message) => {
if (
isExpectedPreviewSetupError(message, {
preview: argv.includes('--preview'),
baseUrl: appUrl,
})
) {
result.expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error') {
let resource = '(location unavailable)';
try {
const url = new URL(message.location()?.url);
resource = url.origin + url.pathname;
} catch {
/* no resource location */
}
result.consoleErrors.push(redact(`${message.text()} — ${resource}`));
}
});
page.on('pageerror', (error) =>
result.consoleErrors.push(redact(error.message)),
);
page.on('requestfailed', (request) => {
if (result.failedRequests.length >= 200) return;
const url = new URL(request.url());
result.failedRequests.push({
resource: redact(url.origin + url.pathname),
error: request.failure()?.errorText,
});
if (message.type() === 'error') result.consoleErrors.push(message.text());
});
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
const url = new URL(appUrl);
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
@@ -295,8 +252,6 @@ try {
await page
.waitForFunction(
() => {
if (window.__godsEyeView.engine.kind === 'navara')
return window.__godsEyeView.engine.surface.tilesLoaded();
const { scene } = window.__godsEyeView.viewer;
if (scene.globe.show) return scene.globe.tilesLoaded;
for (let i = 0; i < scene.primitives.length; i++) {
@@ -353,31 +308,8 @@ try {
// changed state without asking for a render.
let staleFrame = null;
if (alpr) {
const shownImage = await page.screenshot({ type: 'png' });
const shown = await cyanCount(shownImage);
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
if (engine.kind === 'navara') {
await new Promise((resolve, reject) => {
let count = 4;
const timeout = setTimeout(() => {
remove();
reject(new Error('ALPR forced-frame deadline'));
}, 10000);
const remove = engine.frames.postRender.addEventListener(() => {
if (--count === 0) {
remove();
clearTimeout(timeout);
resolve();
} else
requestAnimationFrame(() =>
engine.render.request('alpr-qa-frame'),
);
});
engine.render.request('alpr-qa-frame');
});
return;
}
const { scene } = window.__godsEyeView.viewer;
const mode = scene.requestRenderMode;
scene.requestRenderMode = false;
@@ -385,23 +317,12 @@ try {
await new Promise((resolve) => requestAnimationFrame(resolve));
scene.requestRenderMode = mode;
});
const forcedImage = await page.screenshot({ type: 'png' });
const forced = await cyanCount(forcedImage);
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
staleFrame = {
shown,
forced,
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
};
if (staleFrame.stale) {
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-shown.png`),
shownImage,
);
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-forced.png`),
forcedImage,
);
}
}
const moving = frameStats(await readFrames(page, moveStart, holdStart));
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
-419
View File
@@ -1,419 +0,0 @@
// The application is created once per page: switching engines detaches and
// attaches only the engine-bound parts. This gate runs three Cesium→Navara→
// Cesium round trips and checks that every session object keeps its identity,
// an open panel, typed text and the focused field survive, Escape still does
// what it did before the switches, window/document listeners do not grow,
// and a failed candidate rolls back onto the same session objects.
//
// node scripts/qa-app-once.mjs http://127.0.0.1:<port>/ [--report]
// `--report` prints the measurements without asserting them.
import assert from 'node:assert/strict';
import {
idDifference,
launch,
listenerCounts,
listenerDifference,
openSessionPage,
previewBase,
readSession,
requestEngine,
sessionAddress,
switchEngine,
waitReady,
} from './qa/appSessionHarness.mjs';
const base = previewBase(process.argv[2]);
const reportOnly = process.argv.includes('--report');
const report = { switches: [], failures: [] };
const check = (condition, message, detail) => {
if (condition) return;
report.failures.push({ message, detail });
if (!reportOnly)
assert.fail(`${message}${detail ? ` ${JSON.stringify(detail)}` : ''}`);
};
const FIELD = '#location-search';
const PANEL = 'data-panel';
// The page's visible UI state the switches must leave alone.
const readUi = (page) =>
page.evaluate(
(field, panel) => ({
value: document.querySelector(field)?.value ?? null,
focused: document.activeElement?.id || null,
panelOpen: !document
.getElementById(panel)
?.classList.contains('collapsed'),
}),
FIELD,
PANEL,
);
// Focus the field, press Escape and read what changed; then put the state
// back. The same press before and after the switches must have the same
// effect.
async function escapeEffect(page) {
await page.focus(FIELD);
const before = await readUi(page);
await page.keyboard.press('Escape');
await new Promise((resolve) => setTimeout(resolve, 400));
const after = await readUi(page);
return { before, after };
}
async function prepareUi(page) {
await page.evaluate((panel) => {
const style = window.__godsEyeView.styleManager;
style.setPanelCollapsed(panel, false, { explicit: true });
style.setPanelCollapsed('location-bar', false, { explicit: true });
}, PANEL);
await page.waitForFunction(
(field) => document.querySelector(field)?.offsetParent !== null,
{},
FIELD,
);
await page.evaluate((field) => {
const input = document.querySelector(field);
input.focus();
input.value = 'Reykjav';
input.dispatchEvent(new Event('input', { bubbles: true }));
}, FIELD);
}
// The session's tools: a mark on the annotation board, an imported file and
// the Draw shape, set on Cesium before the switches.
async function prepareTools(page) {
return page.evaluate(async () => {
const annotated = await window.__gevAnnotations.annotate([
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
]);
const file = new File(
[
JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'QA area' },
geometry: {
type: 'Polygon',
coordinates: [
[
[-71.07, 42.35],
[-71.05, 42.35],
[-71.05, 42.36],
[-71.07, 42.35],
],
],
},
},
],
}),
],
'qa-area.geojson',
{ type: 'application/geo+json' },
);
const imported = await window.__gevGeoImport.importFiles([file]);
window.__gevDrawTool.setShape('line');
return { annotated: annotated.drawn, imported: imported[0]?.ok };
});
}
const readTools = (page) =>
page.evaluate(() => ({
marks: window.__gevAnnotations?.count() ?? null,
marksDrawn: window.__gevAnnotations?.attached ?? null,
files: window.__gevGeoImport?.list().length ?? null,
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
drawShape: window.__gevDrawTool?.shape ?? null,
}));
// Listeners whose difference is the parked renderer's: the Navara engine
// keeps its own input, resize and attribution listeners while it is parked
// for a switch back (scripts that are not the session's).
const PARKED_RENDERER = /@navara-engine\.js$/;
const browser = await launch();
try {
const { page, errors } = await openSessionPage(
browser,
sessionAddress(base, { engine: 'cesium' }),
);
await waitReady(page, 'cesium');
// Let the first boot's deferred listeners land before counting.
await new Promise((resolve) => setTimeout(resolve, 2000));
const boot = await readSession(page);
const bootListeners = await listenerCounts(page, { bySource: true });
report.boot = boot;
report.toolsPrepared = await prepareTools(page);
check(
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
'the tools could not be prepared',
report.toolsPrepared,
);
// Let the imported file finish drawing.
await page.waitForFunction(
() => window.__gevGeoImport.diagnostics().drawn === 1,
);
const toolsBefore = await readTools(page);
report.toolsBefore = toolsBefore;
await prepareUi(page);
const escapeBefore = await escapeEffect(page);
report.escapeBefore = escapeBefore;
await prepareUi(page);
const uiBefore = await readUi(page);
report.uiBefore = uiBefore;
const sequence = ['navara', 'cesium', 'navara', 'cesium', 'navara', 'cesium'];
// Dev guard: once an engine is parked, nothing may move its camera or
// ask it to draw until it is attached again (its own resume aside).
await page.evaluate(() => {
window.__qaParkedCalls = [];
window.__qaWatchParked = (engine) => {
if (!engine || engine.__qaWatched) return;
engine.__qaWatched = true;
const watch = (owner, names, label) => {
for (const name of names) {
const original = owner[name];
if (typeof original !== 'function') continue;
owner[name] = function (...args) {
const parked = window.__godsEyeView?.engine !== engine;
const resume = name === 'request' && args[0] === 'engine-resume';
if (parked && !resume && !window.__qaSwitching)
window.__qaParkedCalls.push({
call: `${engine.kind}.${label}.${name}`,
reason: typeof args[0] === 'string' ? args[0] : null,
stack: new Error().stack.split('\n').slice(2, 6).join(' | '),
});
return original.apply(this, args);
};
}
};
watch(
engine.camera,
['setView', 'flyTo', 'lookAt', 'flyToBoundingSphere', 'release'],
'camera',
);
watch(engine.render, ['request', 'setContinuous'], 'render');
};
window.__qaWatchParked(window.__godsEyeView.engine);
});
for (const kind of sequence) {
const started = Date.now();
// The switch itself parks and resumes engines; only the time an engine
// sits parked is watched.
await page.evaluate(() => {
window.__qaSwitching = true;
});
await switchEngine(page, kind);
await page.evaluate(() => {
window.__qaSwitching = false;
window.__qaWatchParked(window.__godsEyeView.engine);
});
const state = await readSession(page);
const ui = await readUi(page);
if (kind === 'navara')
await page.evaluate(() => {
window.__qaParkedNavara = window.__godsEyeView.engine;
});
if (report.switches.length === 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
}
// Both engines draw the session's marks and files (Navara included).
await page.waitForFunction(
() => window.__gevGeoImport.diagnostics().drawn === 1,
{ timeout: 30_000 },
);
const tools = await readTools(page);
check(
tools.marks === toolsBefore.marks &&
tools.files === toolsBefore.files &&
tools.drawShape === toolsBefore.drawShape &&
tools.marksDrawn === true &&
tools.filesDrawn === 1,
`the session tools changed on the switch to ${kind}`,
{ toolsBefore, tools },
);
report.switches.push({
kind,
ms: Date.now() - started,
ids: idDifference(boot.ids, state.ids),
counters: state.counters,
ui,
tools,
});
check(
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
`session ids changed on the switch to ${kind}`,
idDifference(boot.ids, state.ids),
);
check(
ui.value === uiBefore.value,
`typed text lost on the switch to ${kind}`,
ui,
);
check(ui.panelOpen, `open panel closed on the switch to ${kind}`, ui);
check(
ui.focused === uiBefore.focused,
`focus moved on the switch to ${kind}`,
ui,
);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
const after = await readSession(page);
const afterListeners = await listenerCounts(page, { bySource: true });
report.after = after;
// After three round trips the page holds exactly the listeners it held
// after the first boot, apart from the parked Navara renderer's own (it
// stays alive for the switch back), which the first round trip added and
// the later ones did not grow.
report.listeners = listenerDifference(bootListeners, afterListeners);
report.listenersSinceFirstRoundTrip = listenerDifference(
report.firstRoundTrip,
afterListeners,
);
const sessionListeners = Object.keys(report.listeners).filter(
(key) => !PARKED_RENDERER.test(key),
);
check(
sessionListeners.length === 0,
'session listeners on window/document changed after three round trips',
report.listeners,
);
check(
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
'listeners on window/document grew after the first round trip',
report.listenersSinceFirstRoundTrip,
);
check(
after.counters?.attachments === 1 + sequence.length &&
after.counters?.detaches === sequence.length,
'each switch detaches once and attaches once',
after.counters,
);
report.parkedEngineCalls = await page.evaluate(() => window.__qaParkedCalls);
check(
report.parkedEngineCalls.length === 0,
'a parked engine had its camera moved or a frame requested',
report.parkedEngineCalls,
);
check(
after.counters?.applications === 1,
'the application must be created once',
after.counters,
);
const escapeAfter = await escapeEffect(page);
report.escapeAfter = escapeAfter;
check(
JSON.stringify(escapeAfter) === JSON.stringify(escapeBefore),
'Escape behaves differently after the switches',
{ escapeBefore, escapeAfter },
);
// A failed candidate: the parked Navara renderer refuses to resume (as in
// qa-layer-handoff.mjs). The working Cesium attaches again on the same
// session objects.
await prepareUi(page);
const beforeRollback = await readSession(page);
const historyLength = await page.evaluate(() => history.length);
await page.evaluate(() => {
window.__qaRollback = false;
const parked = window.__qaParkedNavara;
const request = parked.render.request;
parked.render.request = function (...args) {
parked.render.request = request;
window.__qaRollback = true;
throw new Error('QA parked renderer failure');
};
});
const sabotaged = true;
if (sabotaged) {
await requestEngine(page, 'navara');
await page.waitForFunction(() => window.__qaRollback === true, {
timeout: 60_000,
});
await waitReady(page, 'cesium');
const rolledBack = await readSession(page);
const ui = await readUi(page);
report.rollback = {
ids: idDifference(beforeRollback.ids, rolledBack.ids),
counters: rolledBack.counters,
ui,
history: (await page.evaluate(() => history.length)) - historyLength,
};
check(
Object.keys(report.rollback.ids).length === 0,
'a rollback changed session ids',
report.rollback,
);
check(
report.rollback.history === 0,
'a rollback added a history entry',
report.rollback,
);
check(
ui.value === uiBefore.value && ui.panelOpen,
'a rollback lost the UI state',
ui,
);
// The page still switches after the rollback.
await switchEngine(page, 'navara');
await switchEngine(page, 'cesium');
const final = await readSession(page);
check(
Object.keys(idDifference(boot.ids, final.ids)).length === 0,
'ids changed after the rollback',
idDifference(boot.ids, final.ids),
);
// A candidate failing after the shell attached to it: a layer's
// drawing refuses to attach on Navara. The shell lets go of the failed
// globe and Cesium attaches again; the next switch works.
await page.evaluate(() => {
window.__qaDataRefused = false;
const module =
window.__godsEyeView.dataManager.layers.get('earthquakes').module;
const attach = module.attachRenderer;
module.attachRenderer = function () {
module.attachRenderer = attach;
window.__qaDataRefused = true;
return Promise.reject(new Error('QA data attach failure'));
};
});
const beforeDataFailure = await page.evaluate(() => history.length);
await requestEngine(page, 'navara');
await page.waitForFunction(() => window.__qaDataRefused === true, {
timeout: 60_000,
});
await waitReady(page, 'cesium');
const afterDataFailure = await readSession(page);
report.dataFailure = {
ids: idDifference(boot.ids, afterDataFailure.ids),
counters: afterDataFailure.counters,
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
};
check(
Object.keys(report.dataFailure.ids).length === 0 &&
report.dataFailure.history === 0,
'a candidate failing in the data part changed the session',
report.dataFailure,
);
await switchEngine(page, 'navara');
await switchEngine(page, 'cesium');
check(
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
.length === 0,
'ids changed after the data-part failure',
);
}
report.errors = errors;
check(errors.length === 0, 'page errors', errors);
console.log(JSON.stringify(report, null, 2));
if (!reportOnly) console.log('PASS application created once; session kept');
} finally {
await browser.close();
}
-64
View File
@@ -23,70 +23,6 @@ export function redact(text) {
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
}
/** The standalone Vite preview has no setup API; opt in explicitly. */
export function isExpectedPreviewSetupError(
message,
{ preview = false, baseUrl } = {},
) {
if (
!preview ||
message.type() !== 'error' ||
!/^Failed to load resource: .*\b404\b/.test(message.text())
)
return false;
try {
const url = new URL(message.location()?.url);
return (
url.origin === new URL(baseUrl).origin &&
url.pathname === '/api/setup/status'
);
} catch {
return false;
}
}
// The runtime evidence gate shares one strict runtime verdict across its main page and
// every share-link page. Response diagnostics never retain query credentials.
export function watchW1Page(page, report, { name, preview = false, baseUrl }) {
const errors = (report.browserErrors ??= []);
report.expectedPreviewErrors ??= [];
report.failedResponses ??= [];
(report.runtimePages ??= []).push(name);
const resourcePath = (value) => {
try {
const url = new URL(value);
return redact(url.origin + url.pathname);
} catch {
return '(location unavailable)';
}
};
page.on('console', (message) => {
if (isExpectedPreviewSetupError(message, { preview, baseUrl })) {
report.expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error')
errors.push(
`[${name}] ${redact(message.text())} — ${resourcePath(message.location()?.url)}`,
);
});
for (const event of ['pageerror', 'error'])
page.on(event, (error) =>
errors.push(`[${name}] ${redact(error.message)}`),
);
page.on('response', (response) => {
if (
response.status() >= 400 &&
report.failedResponses.length < EVIDENCE_LIMIT
)
report.failedResponses.push({
page: name,
status: response.status(),
resource: resourcePath(response.url()),
});
});
}
function push(list, entry) {
list.push(entry);
if (list.length > EVIDENCE_LIMIT)
-152
View File
@@ -1,152 +0,0 @@
import assert from 'node:assert/strict';
import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
import { installCctvFixture } from './fixtures/w2/cctv.mjs';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]);
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l=';
const { stdout: video } = await promisify(execFile)(
'ffmpeg',
[
'-hide_banner',
'-loglevel',
'error',
'-f',
'lavfi',
'-i',
'testsrc2=size=320x180:rate=12',
'-t',
'60',
'-an',
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-preset',
'ultrafast',
'-threads',
'1',
'-movflags',
'frag_keyframe+empty_moov',
'-f',
'mp4',
'pipe:1',
],
{ encoding: 'buffer', maxBuffer: 16000000 },
);
const strict = createStrict({ origin: url.origin, layers: ['cctv'] });
await strict.run({}, async (b) => {
const p = await b.newPage();
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message);
});
await installCctvFixture(p, { video });
let catalogs = 0;
p.on('request', (req) => {
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
});
await p.goto(url.href);
await p.waitForFunction(
() =>
window.__godsEyeView?.engine?.kind === 'navara' &&
document.querySelector('#loading-screen.hidden'),
);
await p.evaluate(async () => {
const { dataManager, styleManager, tileset } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Expected keyless preview');
await styleManager.initialRestorePromise;
await dataManager.setEnabled('cctv', true, { origin: 'user' });
window.qaCctv = dataManager.layers.get('cctv').module;
window.qaCctv.setParams({
selectedCameraId: 'w2-cctv-video',
showProjection: true,
coverageMode: 'viewshed',
calibration: { patch: { heightM: 31, pitchDeg: 20 } },
});
});
await p.waitForFunction(
() =>
window.qaCctv.getActiveVideoElement()?.videoWidth === 320 &&
window.qaCctv.getActiveVideoElement().currentTime > 0.1,
{ timeout: 40000 },
);
const first = await p.evaluate(() => {
window.qaVideo = window.qaCctv.getActiveVideoElement();
return window.qaVideo.getVideoPlaybackQuality().totalVideoFrames;
});
let previous = first;
const count = catalogs;
for (const kind of ['cesium', 'navara']) {
await strict.switchTo(p, kind);
await p.waitForFunction(
(n) =>
window.qaCctv.getActiveVideoElement()?.getVideoPlaybackQuality()
.totalVideoFrames > n,
{},
previous,
);
const v = await p.evaluate(() => ({
same:
window.qaCctv ===
window.__godsEyeView.dataManager.layers.get('cctv').module,
media: window.qaCctv.getActiveVideoElement() === window.qaVideo,
frames: window.qaVideo.getVideoPlaybackQuality().totalVideoFrames,
paused: window.qaVideo.paused,
params: window.qaCctv.getParams(),
plane: !!window.qaCctv
._cctvRecordsForTest()
.find((r) => r.camera.id === 'w2-cctv-video').projection?.planeEntity,
projection: (() => {
const r = window.qaCctv
._cctvRecordsForTest()
.find((r) => r.camera.id === 'w2-cctv-video');
return {
show: r.projection?.planeEntity?.show === true,
visible: r.projection?.planeVisible === true,
calibration: r.camera.calibration,
};
})(),
}));
console.log(
kind,
v.same,
v.media,
v.frames,
v.plane,
v.params.selectedCameraId,
v.params.coverageMode,
);
assert.ok(v.same && v.media && v.plane && !v.paused);
assert.equal(v.params.selectedCameraId, 'w2-cctv-video');
assert.equal(v.params.coverageMode, 'viewshed');
assert.equal(catalogs, count);
await strict.passed(
p,
'cctv',
'same model and video element decoding new frames; textured projection plane; no catalog read',
);
// The calibration patch (31 m mount, 20° pitch) and the plane's
// visibility are kept on the new engine.
const { calibration } = v.projection;
await strict.check(
p,
'cctv',
'calibration patch kept (31 m, 20° pitch); monitor plane visible',
v.projection.show &&
v.projection.visible &&
calibration?.heightM === 31 &&
calibration?.pitchDeg === 20 &&
v.params.calibration?.values?.heightM === 31 &&
v.params.calibration?.values?.pitchDeg === 20,
v.projection,
);
previous = v.frames;
}
assert.deepEqual(errors, []);
console.log('PASS retained CCTV catalog, decoder and textured projection');
});
+64 -222
View File
@@ -40,10 +40,12 @@
* issuing zero transient samples, without touching the save-gated store;
* camera-controller inputs restore and parts hide on mode off.
*
* This legacy harness is the geometry, lifecycle and real-input oracle.
* Migration runs use headless Metal and strict runtime error observation.
* Screenshots remain visual-review evidence here; a separate pixel gate must
* independently prove actual marker, image-plane and video pixels.
* SwiftShader caveat (proven in Task 3): under headless software GL, plane
* image materials do not visually FILL — screenshotting for a pixel-based
* pass/fail is not viable here. All four assertion groups therefore check
* entity/counter/localStorage/network STATE (GL-independent), never rendered
* pixels. Screenshots are still captured for human visual review but do not
* gate the exit code.
*
* No `cesium` package import for in-page math: the app does not expose a
* `window.Cesium` global, so every in-page evaluate() either (a) reads plain
@@ -69,17 +71,12 @@
* so the coincide check is deterministic whichever camera activates, but it
* is no longer needed to avoid a plane/wireframe disagreement.
*
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
* Routine keyless builds also pass --terrain-globe: assert zero scene floor
* samples instead of claiming mesh-floor coverage on a globe-only map.
* Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173
*
* Exit 0 = all asserts passed. Non-zero = a hard failure.
*/
import puppeteer from 'puppeteer';
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { installCctvFixture } from './fixtures/w2/cctv.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -98,33 +95,12 @@ const getOpt = (name, dflt) => {
};
const getFlag = (name) => argv.includes(name);
const explicitUrl = getOpt('--url', process.env.QA_BASE_URL);
const output = getOpt('--out');
if (!explicitUrl || !output)
throw new Error('Explicit --url and --out are required');
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
const terrainGlobe = getFlag('--terrain-globe');
if (terrainGlobe && mapPolicy.allowPaidMaps)
throw new Error('Terrain-globe QA must keep paid maps blocked');
const CCTV_URL = new URL(explicitUrl);
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
CCTV_URL.port,
)
)
throw new Error('CCTV QA may not use a reserved port');
if (getFlag('--headful')) throw new Error('CCTV migration QA is headless only');
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
CCTV_URL.searchParams.set('welcome', '0');
const APP_URL = CCTV_URL.href;
const SHOTS_DIR = path.resolve(output);
const evidence = { checks: [], browserErrors: [], pass: false };
const saveEvidence = () => {
fs.mkdirSync(SHOTS_DIR, { recursive: true });
fs.writeFileSync(
path.join(SHOTS_DIR, 'result.json'),
JSON.stringify(evidence, null, 2) + '\n',
);
};
const HEADFUL = getFlag('--headful');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -151,9 +127,8 @@ function findChromeExecutable() {
return null;
}
const results = evidence.checks;
const results = [];
function record(name, ok, detail) {
detail = redact(detail);
results.push({ name, ok, detail });
const tag =
ok === null
@@ -450,24 +425,24 @@ function findSafeCalibrationPatch(basePose, groundAltM) {
async function main() {
console.log(`\nCCTV v2 Subsystem Proof`);
console.log(` App URL : ${redact(APP_URL)}`);
console.log(' Mode : headless Metal\n');
console.log(` App URL : ${APP_URL}`);
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
try {
const res = await fetch(APP_URL, { method: 'GET' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(
redact(`Dev server not reachable: ${e.message}`),
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
);
throw new Error('CCTV prerequisite unavailable');
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: true,
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.
@@ -476,46 +451,32 @@ async function main() {
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-angle=metal',
'--enable-gpu',
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
],
});
const consoleErrors = evidence.browserErrors;
const consoleErrors = [];
let exitCode = 0;
let fixture = null;
try {
const page = await browser.newPage();
await blockPaidQaMaps(page, mapPolicy);
const terrainRequests = new Set();
let terrainActivityAt = 0;
page.on('request', (request) => {
if (new URL(request.url()).pathname !== '/api/terrain/heights') return;
terrainRequests.add(request);
terrainActivityAt = Date.now();
});
for (const event of ['requestfinished', 'requestfailed'])
page.on(event, (request) => {
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
});
await page.setViewport({ width: 1280, height: 800 });
watchW1Page(page, evidence, {
name: 'cctv-legacy',
baseUrl: APP_URL,
preview: getFlag('--preview'),
});
fixture = getFlag('--fixture')
? await installCctvFixture(page)
: null;
await page.evaluateOnNewDocument(() => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
page.on('console', (msg) => {
if (msg.type() === 'error') {
const t = msg.text();
if (
!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(
t,
)
) {
consoleErrors.push(t);
}
}
});
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -529,35 +490,8 @@ async function main() {
),
{ timeoutMs: 60000 },
);
await page.evaluate(async () => {
const app = window.__godsEyeView;
await app.styleManager.initialRestorePromise;
app.styleManager.orbitController.stop();
for (const [id, layer] of app.dataManager.layers)
if (layer.enabled) await app.dataManager.setEnabled(id, false);
app.styleManager._setDetectionMode('OFF');
});
evidence.surface = await page.evaluate(() => {
const app = window.__godsEyeView;
return {
map: app.mapStackController.getActiveId(),
globeVisible: app.viewer.scene.globe.show,
tileset: Boolean(app.tileset),
googleKeyPresent: Boolean(window.__GOOGLE_MAPS_API_KEY__),
photorealAvailable:
app.mapStackController.isStackAvailable('photoreal'),
};
});
evidence.surfaceMode = terrainGlobe ? 'terrain-globe' : 'mesh-floor';
if (terrainGlobe) {
const keyless =
evidence.surface.globeVisible === true &&
!evidence.surface.tileset &&
!evidence.surface.googleKeyPresent &&
!evidence.surface.photorealAvailable;
record('keyless terrain-globe startup', keyless);
if (!keyless) throw new Error('Expected a keyless globe before CCTV QA');
}
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);
@@ -595,7 +529,7 @@ async function main() {
console.error(
'\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m',
);
throw new Error('CCTV prerequisite unavailable');
process.exit(2);
}
const readCounters = () =>
@@ -623,20 +557,9 @@ async function main() {
// ~800ms per real ground sample measured under SwiftShader (each
// scene.sampleHeight forces tile loads at the probe point) — real GPU is
// far faster, so this is a headless-CI ceiling, not an app expectation.
evidence.cameraIds = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('cctv')
.module.getUIState()
.cameras.map(({ id }) => id)
.sort(),
);
if (fixture)
record(
'real catalog parser retains every fixture camera ID',
JSON.stringify(evidence.cameraIds) ===
JSON.stringify(fixture.cameras.map(({ id }) => id).sort()),
);
const drainBudgetMs = Math.max(30000, camCount * 800 + 30000);
const drainBudgetMs = HEADFUL
? Math.max(30000, camCount * 800 + 30000)
: Math.min(120000, Math.max(30000, camCount * 800 + 30000));
console.log(
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
);
@@ -651,10 +574,12 @@ async function main() {
);
record(
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
drained,
drained ? true : HEADFUL ? false : null,
drained
? 'loading.active === false'
: 'timed out waiting for the real-GPU geometry drain',
: HEADFUL
? 'timed out waiting for drain on the real-GPU sign-off path'
: 'headless GL geometry sampling did not settle; current headful evidence is required',
);
const cAfterDrain = await readCounters();
@@ -692,7 +617,6 @@ async function main() {
.activeCameraId,
);
evidence.activeCameraId = activeId;
const pickDeltaActivation =
cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay;
record(
@@ -736,32 +660,9 @@ async function main() {
// (existence, not just ceiling). The wait below tolerates the update-tick
// latency of the completion pass (update interval is 10s).
// -----------------------------------------------------------------------
console.log('Waiting for tiles + the one-shot completion pass...');
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false;
if (tilesSeenLoaded && !terrainGlobe) {
sampleFloorOk = await page
.waitForFunction(
(base) => window.__qaCounters.sampleHeight - base >= 1,
{ timeout: 30000 },
c0.sampleHeight,
)
.then(() => true)
.catch(() => false);
}
const cFloor = await readCounters();
record(
terrainGlobe
? 'terrain-globe resolves from priors with zero scene ground samples'
: '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
terrainGlobe
? cFloor.sampleHeight - c0.sampleHeight === 0
: tilesSeenLoaded && sampleFloorOk,
tilesSeenLoaded
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run',
const globeMode = await page.evaluate(
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
);
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
@@ -1133,27 +1034,8 @@ async function main() {
return serializeGeom(camId);
})(${JSON.stringify(camId)})`);
const geomBefore30s = await serializeGeom(activeId);
await sleep(28000);
evidence.staticIdle = await page.evaluate(async () => {
const app = window.__godsEyeView;
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
let frames = 0;
const remove = app.engine.frames.postRender.addEventListener(() => frames++);
const started = performance.now();
await new Promise((resolve) => setTimeout(resolve, 2000));
remove();
return { frames, durationMs: performance.now() - started,
governor: getRenderGovernorDiagnostics() };
});
await sleep(30000);
const geomAfter30s = await serializeGeom(activeId);
evidence.geometry = {
initial: geomInfo,
beforeIdle: geomBefore30s,
afterIdle: geomAfter30s,
};
evidence.countersAfterIdle = await readCounters();
record('static image projection settles to zero frames over 2s',
evidence.staticIdle.frames === 0, JSON.stringify(evidence.staticIdle));
const geomStable =
JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s);
record(
@@ -1325,7 +1207,7 @@ async function main() {
);
// =========================================================================
// Group 4: frame-loop health (state oracle; media pixels have a separate gate)
// Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat)
// =========================================================================
console.log(
'Checking frame-loop health (placeholder -> frame swap, URL family)...',
@@ -1582,18 +1464,25 @@ async function main() {
const gev = window.__godsEyeView;
const viewer = gev.viewer;
const scene = viewer.scene;
const canvasPoint = { x: emptyPoint.canvasX, y: emptyPoint.canvasY };
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
const canvasPoint = new Cesium.Cartesian2(
emptyPoint.canvasX,
emptyPoint.canvasY,
);
const ray = viewer.camera.getPickRay(canvasPoint);
if (!ray) return null;
let position = null;
let projected = null;
for (const distance of [1_000, 10_000, 100_000, 1_000_000]) {
const candidate = ray.constructor.getPoint(
const candidate = Cesium.Ray.getPoint(
ray,
distance,
new ray.origin.constructor(),
new Cesium.Cartesian3(),
);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
candidate,
);
const screen = scene.cartesianToCanvasCoordinates(candidate);
if (
screen &&
Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2
@@ -1609,7 +1498,7 @@ async function main() {
position,
point: {
pixelSize: 64,
color: new scene.backgroundColor.constructor(1, 0, 1, 1),
color: Cesium.Color.MAGENTA,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
@@ -1648,7 +1537,7 @@ async function main() {
!siblingTarget
? null
: siblingOwnsPick ||
(siblingRendererStalled ? null : false),
(siblingRendererStalled && !HEADFUL ? null : false),
siblingTarget
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
: 'no previously verified empty canvas ray was available',
@@ -1786,24 +1675,6 @@ async function main() {
});
const cBeforeViewshed = await readCounters();
await page.evaluate(() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
const events = [];
window.__qaViewshedTrace = events;
for (const method of ['add', 'remove']) {
const original = primitives[method].bind(primitives);
primitives[method] = (primitive, ...args) => {
if (primitive?._gevViewshed)
events.push({
method,
camera: primitive._gevViewshed,
at: performance.now(),
stack: new Error().stack,
});
return original(primitive, ...args);
};
}
});
const modeAfterSet = await page.evaluate(() => {
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
mod.setParams({ coverageMode: 'viewshed' });
@@ -1854,18 +1725,6 @@ async function main() {
mod.setParams({ coverageMode: 'viewshed' });
});
await sleep(400);
// NEXT starts an asynchronous footprint-height lookup. Its completion is
// allowed to update geometry; the unchanged 8s idle window starts after it.
const terrainDeadline = Date.now() + 35000;
while (
(terrainRequests.size || Date.now() - terrainActivityAt < 250) &&
Date.now() < terrainDeadline
)
await sleep(50);
const terrainSettled =
terrainRequests.size === 0 && Date.now() - terrainActivityAt >= 250;
record('footprint requests settle before viewshed idle', terrainSettled);
if (!terrainSettled) throw new Error('Footprint requests did not settle');
await page.evaluate(() => {
const prims = window.__godsEyeView.viewer.scene.primitives;
window.__qaViewshedRefs = [];
@@ -1887,9 +1746,6 @@ async function main() {
return { sameCount, sameIdentity, count: now.length };
});
const cAfterViewshed = await readCounters();
evidence.viewshedTrace = await page.evaluate(
() => window.__qaViewshedTrace,
);
const lateFloorSamples =
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
record(
@@ -1916,11 +1772,6 @@ async function main() {
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
// =========================================================================
console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...');
// NEXT above selects a different catalog camera without moving the view.
// Frame that actual camera before requiring an on-screen physical drag.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv')
.module.setParams({ focusSelected: true, focusDurationSec: 0.2 }));
await sleep(800);
const GIZMO_PARTS = [
'ring-heading',
'ring-pitch',
@@ -2169,7 +2020,7 @@ async function main() {
record(
'east-arrow drag changes the east offset (live, unsaved)',
null,
`${missDetail} — required physical drag evidence is missing from this headless Metal run`,
`${missDetail} — verify on a current headful GPU surface`,
);
record(
'east-arrow drag keeps mount elevation frozen until mouse-up',
@@ -2193,7 +2044,7 @@ async function main() {
);
} else {
const unavailable =
'no on-screen east-arrow shaft found in the headless Metal view';
'no on-screen east-arrow shaft found under this GL stack — verify on real GPU';
record(
'east-arrow drag changes the east offset (live, unsaved)',
null,
@@ -2244,8 +2095,8 @@ async function main() {
);
// -----------------------------------------------------------------------
// Screenshots supplement this state/geometry oracle. Positive rendered
// media pixels are a separate mandatory pixel gate.
// Screenshots for human visual review (NOT a pass/fail signal — SwiftShader
// cannot fill plane image materials under headless software GL).
// -----------------------------------------------------------------------
await sleep(1000);
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
@@ -2259,16 +2110,10 @@ async function main() {
);
for (const r of results) {
if (r.ok !== true) exitCode = 1;
if (r.ok === false) exitCode = 1;
}
} finally {
await fixture?.dispose();
await browser.close();
evidence.pass =
results.length > 0 &&
results.every((r) => r.ok === true) &&
!consoleErrors.length;
saveEvidence();
}
console.log('\n' + '─'.repeat(60));
@@ -2283,9 +2128,6 @@ async function main() {
}
main().catch((e) => {
evidence.pass = false;
evidence.error = redact(e?.stack || e);
saveEvidence();
console.error('Harness error:', evidence.error);
console.error('\x1b[31mHarness error:\x1b[0m', e);
process.exit(3);
});
+6 -30
View File
@@ -4,7 +4,6 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
@@ -30,7 +29,6 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
await blockPaidQaMaps(page);
const failures = [];
const consoleErrors = [];
const localHttpErrors = [];
@@ -829,7 +827,6 @@ try {
const readinessPage = await browser.newPage();
let deferredInstallationReadiness;
try {
await blockPaidQaMaps(readinessPage);
await readinessPage.setViewport({ width: 1440, height: 900 });
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
await readinessPage.setRequestInterception(true);
@@ -1153,14 +1150,6 @@ try {
},
{ timeout: 10_000 },
);
const startsClear = await page.evaluate(() => {
const hidden = ['left-panel-stack', 'intel-hud', 'corner-actions',
'cockpit-context', 'cockpit-signal-stream', 'cockpit-utility-controls'];
return hidden.every((id) => document.getElementById(id)?.getClientRects().length === 0)
&& document.getElementById('cockpit-speed-value').getClientRects().length > 0;
});
check('Cockpit starts with flight instruments and hides the map panels', startsClear);
await page.click('#cockpit-panels-toggle');
const firstCockpitContact = await page.evaluate(() => {
const awareness = window.__godsEyeView.dataManager.layers
.get('military-awareness')?.module;
@@ -1522,9 +1511,6 @@ try {
const before = read();
document.getElementById('cockpit-entry')?.click();
await new Promise((resolve) => setTimeout(resolve, 120));
const startsClear = document.getElementById('cockpit-panels-toggle').getAttribute('aria-pressed') === 'false';
document.getElementById('cockpit-panels-toggle').click();
await new Promise((resolve) => requestAnimationFrame(resolve));
const during = read();
manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -1545,7 +1531,6 @@ try {
before,
during,
after: read(),
startsClear,
exited,
clearancePx,
contactAutoCollapsed,
@@ -1555,7 +1540,7 @@ try {
});
check(
'Cockpit temporarily collapses standard panels and restores the exact map layout on exit',
cockpitPanelRoundTrip.exited && cockpitPanelRoundTrip.startsClear
cockpitPanelRoundTrip.exited
&& Object.values(cockpitPanelRoundTrip.during).every(Boolean)
&& JSON.stringify(cockpitPanelRoundTrip.after)
=== JSON.stringify(cockpitPanelRoundTrip.before)
@@ -1620,7 +1605,6 @@ try {
const states = [read()];
for (let index = 0; index < 6; index += 1) {
current.click();
await Promise.resolve();
states.push(read());
}
const originalStyle = manager.activeStyle;
@@ -1632,10 +1616,8 @@ try {
const enteredOnNoir = await reenter();
const entryNoir = read();
cockpit.setVisionMode('optical');
await Promise.resolve();
const normalInsideNoir = read();
cockpit.setVisionMode('crt');
await Promise.resolve();
const cockpitOnlyCrt = read();
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
await settle();
@@ -1710,9 +1692,6 @@ try {
await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') });
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical'));
await page.$eval('#cockpit-panels-toggle', button => {
if (button.getAttribute('aria-pressed') === 'false') button.click();
});
const desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => {
const manager = window.__godsEyeView.styleManager;
const hud = document.getElementById('cockpit-hud');
@@ -1898,7 +1877,7 @@ try {
// the wall Cockpit actually solves against — not by injecting an anchor.
// The card is bottom-anchored, so an explicit height moves its top edge.
const minTop = Math.max(96, window.innerHeight * 0.12);
const moveSignalTopTo = async (wantTop) => {
const moveSignalTopTo = (wantTop) => {
for (let pass = 0; pass < 8; pass += 1) {
const rect = signal.getBoundingClientRect();
const delta = wantTop - rect.top;
@@ -1911,11 +1890,9 @@ try {
// the now-shorter strip, so idempotence is asserted where the strip's
// composition is unchanged.)
manager.cockpitView.syncSignalLayout();
await Promise.resolve();
const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top');
const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only');
manager.cockpitView.syncSignalLayout();
await Promise.resolve();
return {
firstPassTop,
firstPassPrimaryOnly,
@@ -1926,7 +1903,6 @@ try {
signal.style.maxHeight = 'none';
signal.style.height = '150px';
manager.cockpitView.syncSignalLayout();
await Promise.resolve();
const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height);
const collapsedHeight = Math.max(50, radioControl.scrollHeight);
const stripHeight = expandedHeight + collapsedHeight + 7;
@@ -1951,11 +1927,11 @@ try {
// because 150 px of card leaves a corridor sized for the OLD strip. Seed from
// the measured requirement instead, which holds for any future panel height,
// and assert the precondition rather than assuming it.
const roomySeed = await moveSignalTopTo(exactSignalTop + 80);
const roomySeed = moveSignalTopTo(exactSignalTop + 80);
const roomySeedExpanded = !utility.classList.contains('layout-primary-only')
&& roomySeed.firstPassPrimaryOnly === false;
const exact = {
...await moveSignalTopTo(exactSignalTop),
...moveSignalTopTo(exactSignalTop),
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
primaryOnly: utility.classList.contains('layout-primary-only'),
siblingVisible: getComputedStyle(radioControl).display !== 'none',
@@ -1965,7 +1941,7 @@ try {
const constrained = {
// One pixel on the far side of the same fit (`exactSignalTop` already sits
// one pixel inside it), so this is still the one-pixel constraint it claims.
...await moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
...moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
primaryOnly: utility.classList.contains('layout-primary-only'),
siblingDisplay: getComputedStyle(radioControl).display,
@@ -1976,7 +1952,7 @@ try {
<= signal.getBoundingClientRect().top - 7,
};
const restored = {
...await moveSignalTopTo(exactSignalTop + 80),
...moveSignalTopTo(exactSignalTop + 80),
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
primaryOnly: utility.classList.contains('layout-primary-only'),
siblingVisible: getComputedStyle(radioControl).display !== 'none',
-267
View File
@@ -1,267 +0,0 @@
import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
const strict = createStrict({
origin: base.origin,
layers: ['military-awareness', 'military-installations'],
});
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
await p.evaluateOnNewDocument(() => {
const fetch = window.fetch.bind(window);
window.fetch = (input, options) => {
const { pathname } = new URL(input.url ?? input, document.baseURI);
if (pathname === '/api/vessels')
return Promise.resolve(
Response.json({
status: 'live',
rows: ['123456789', '223456789'].map((mmsi, i) => ({
mmsi,
name: 'Toronto ' + i,
lat: 43.65,
lon: -79.38 + i * 0.05,
last_position_epoch: Date.now() / 1000,
})),
}),
);
// Contacts reads air quality near the subject; no station here.
if (
new URL(input.url ?? input, document.baseURI).hostname ===
'api.weather.gc.ca'
)
return Promise.resolve(
Response.json({ type: 'FeatureCollection', features: [] }),
);
if (pathname === '/api/vessels/track')
return Promise.resolve(Response.json({ samples: [] }));
if (pathname === '/api/flights')
return Promise.resolve(
Response.json({ time: Date.now() / 1000, states: [] }),
);
if (pathname === '/api/military')
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
// One mapped station next to the Contacts subject, with a footprint.
if (pathname === '/api/military-installations') {
window.qaInstallationReads = (window.qaInstallationReads ?? 0) + 1;
const ring = [
[43.665, -79.345],
[43.665, -79.315],
[43.68, -79.315],
[43.68, -79.345],
[43.665, -79.345],
];
return Promise.resolve(
Response.json({
status: 'ok',
elements: [
{
type: 'way',
id: 424242,
tags: { military: 'naval_base', name: 'QA Harbour Station' },
center: { lat: 43.6725, lon: -79.33 },
geometry: ring.map(([lat, lon]) => ({ lat, lon })),
},
],
}),
);
}
return fetch(input, options);
};
window.qaSelected = [];
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
window.addEventListener(type, ({ detail }) =>
window.qaSelected.push({
type,
id: detail?.id,
layerId: detail?.layerId,
}),
);
});
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { dataManager, styleManager, tileset } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Require keyless preview');
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
const outcome = await styleManager.setContextMode('contacts');
if (!outcome.ok) throw Error(JSON.stringify(outcome));
window.qaContext = dataManager.layers.get('military-awareness').module;
window.qaSites = dataManager.layers.get('military-installations').module;
const vessels = dataManager.layers.get('ais-live-vessels').module;
if (!vessels.selectById('123456789')) throw Error('Missing first vessel');
if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' }))
throw Error('Cannot navigate to the second vessel');
});
const sample = () =>
p.evaluate(() => {
const { dataManager, styleManager, engine } = window.__godsEyeView;
return {
context:
window.qaContext ===
dataManager.layers.get('military-awareness').module,
sites:
window.qaSites ===
dataManager.layers.get('military-installations').module,
mode: styleManager.getContextModeState().mode,
subject: window.qaContext.getContextSnapshot()?.subject?.id,
history:
window.qaContext._getAwarenessNavigationStateForTest().historyKeys,
enabled: [...dataManager.getEnabledLayerIds()].sort(),
ring:
engine.kind === 'navara'
? engine.lines
.list()
.filter((line) => line.id === 'gev-awareness-rim').length
: null,
};
});
// The station zone: the fixture's installation is the one mapped site
// within the Contacts radius of the subject, and the engine draws it where
// a pointer can reach it (the engine's own picker returns it at an
// uncovered canvas point at its marker or inside its footprint).
const inside = [fromDegrees(-79.33, 43.6725)];
for (let i = 1; i < 6; i++)
for (let k = 1; k < 6; k++)
inside.push(fromDegrees(-79.345 + 0.005 * k, 43.665 + 0.0025 * i));
const zone = async () => {
await p.waitForFunction(
() =>
!window.qaSites.getStats().loading &&
window.qaSites
.getAnalystRecords()
.some((r) => r.name === 'QA Harbour Station'),
{ timeout: 30000 },
);
const drawn = await p
.waitForFunction(
(points) => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (const world of points) {
const centre = engine.projector.worldToWindow(world, {});
if (!centre) continue;
for (const [dx, dy] of [
[0, 0],
[0, -8],
[8, 0],
[-8, 0],
[0, 8],
]) {
const at = { x: centre.x + dx, y: centre.y + dy };
if (
!canvas.contains(
document.elementFromPoint(box.left + at.x, box.top + at.y),
)
)
continue;
const id = engine.picker.pick(at)?.id;
if (String(id?.id ?? id) === 'osm:way:424242')
return [Math.round(at.x), Math.round(at.y)];
}
}
return false;
},
{ timeout: 20000 },
inside,
)
.then((handle) => handle.jsonValue())
.catch(() => null);
return p.evaluate(
(drawn) => ({
drawn,
records: window.qaSites.getAnalystRecords().map((r) => r.name),
coverage: window.qaSites.getStats().coverage,
count: window.qaSites.getStats().count,
reads: window.qaInstallationReads,
subject: window.qaContext.getContextSnapshot()?.subject?.id,
}),
drawn,
);
};
const checkZone = async () => {
const station = await zone();
await strict.check(
p,
'military-installations',
'the station zone holds the fixture station, drawn and pickable',
station.count === 1 &&
station.records.length === 1 &&
station.records[0] === 'QA Harbour Station' &&
station.coverage?.kind === 'subject' &&
Array.isArray(station.drawn),
station,
);
return station;
};
const firstZone = await checkZone();
const baseline = await sample();
console.log('baseline', JSON.stringify(baseline));
assert.equal(baseline.mode, 'flights');
assert.equal(baseline.subject, '223456789');
assert.ok(baseline.history.includes('ais-live-vessels:123456789'));
assert.ok(baseline.ring >= 1);
await strict.passed(
p,
'military-awareness',
'Contacts subject, navigation history and awareness rim',
baseline,
);
for (const kind of ['cesium', 'navara']) {
await strict.switchTo(p, kind);
const current = await sample();
console.log('handoff', kind, JSON.stringify(current));
assert.deepEqual(current, {
...baseline,
ring: kind === 'navara' ? baseline.ring : null,
});
await strict.passed(
p,
'military-awareness',
'same awareness model, Contacts mode, subject, history and enabled layers',
);
await strict.passed(
p,
'military-installations',
'same installations model and enabled through the switch',
);
const station = await checkZone();
await strict.check(
p,
'military-installations',
'the switch reads no installation snapshot',
station.reads === firstZone.reads,
{ before: firstZone.reads, after: station.reads },
);
}
await checkZone();
const restored = await p.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
const result = await styleManager.setContextMode('off');
return (
result.ok &&
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
JSON.stringify(window.qaBefore)
);
});
assert.ok(
restored,
'Restore layers enabled by the user before entering Contacts',
);
assert.deepEqual(errors, []);
console.log(
'PASS Contacts history, dependency ownership and exit restore across both globes',
);
});
-189
View File
@@ -1,189 +0,0 @@
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const strict = createStrict({ origin: base.origin, layers: ['directions'] });
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
let routeReads = 0,
release;
await p.setRequestInterception(true);
p.on('request', async (req) => {
const u = new URL(req.url());
const json = (v) =>
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(v),
});
if (u.pathname === '/api/terrain/heights') {
const cells = (u.searchParams.get('points') ?? '').split(';');
return json({ results: cells.map(() => ({ ellipsoid: 34 })) });
}
if (u.pathname !== '/api/route') return req.continue();
routeReads++;
const [a, b] = u.searchParams
.get('coords')
.split(';')
.map((p) => p.split(',').map(Number));
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
if (routeReads === 1)
await new Promise((resolve) => {
release = resolve;
});
return json({
ok: true,
geometry: [a, mid, b],
distanceM: 9000,
durationS: 800,
steps: [a, mid, b].map(([lon, lat], i) => ({
lon,
lat,
instruction: 'Route step ' + i,
distanceM: 3000,
durationS: 120,
})),
});
});
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { tileset, dataManager } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
window.qaRoute = dataManager.layers.get('directions').module;
await dataManager.setEnabled('directions', true, { origin: 'user' });
});
for (const [letter, x] of [
['a', 480],
['b', 645],
]) {
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
await p.mouse.click(x, 440);
await new Promise((resolve) => setTimeout(resolve, 450));
}
assert.equal(routeReads, 1);
await strict.passed(
p,
'directions',
'A and B set by clicks on the globe; one route request',
);
await strict.switchTo(p, 'cesium');
release();
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
await p.waitForFunction(() => {
window.qaRoute.setParams({ step: 1 });
return window.qaRoute.getRowControls().list.items[1]?.active;
});
await strict.passed(
p,
'directions',
'the route asked for on Navara arrives on Cesium; a step is selectable',
);
const transfer = async (kind) => {
await strict.switchTo(p, kind);
assert.equal(
await p.evaluate(
() =>
window.__godsEyeView.dataManager.layers.get('directions').module ===
window.qaRoute,
),
true,
);
assert.equal(routeReads, 1);
await strict.passed(
p,
'directions',
'same model; the switch requests no route',
);
};
await transfer('navara');
assert.equal(
await p.evaluate(
() => window.qaRoute.getRowControls().list.items[1].active,
),
true,
);
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
await p.waitForFunction(
() =>
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
);
const pos = () =>
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
await new Promise((resolve) => setTimeout(resolve, 1800));
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
for (const kind of ['cesium', 'navara']) {
const before = await pos();
await transfer(kind);
await p.waitForFunction(
() =>
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
?.active,
);
const at = await pos();
await new Promise((resolve) => setTimeout(resolve, 1500));
const next = await pos();
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
assert.equal(resumed.motionId, progress.motionId);
assert.ok(resumed.traveledM > progress.traveledM);
progress = resumed;
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
await strict.passed(
p,
'directions',
'FLY continues with the same motion and advances',
{ moved: d(at, next) },
);
}
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
// CLEAR releases the camera: the route's motion ends, the camera stays
// where it is, and a wheel zoom by the person moves it and is kept.
await new Promise((resolve) => setTimeout(resolve, 500));
const released = await p.evaluate(() => ({
motion: window.qaRoute.getFlightProgress(),
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
?.active,
}));
const still = await pos();
await new Promise((resolve) => setTimeout(resolve, 1500));
const later = await pos();
const height = () =>
p.evaluate(() => window.__godsEyeView.engine.camera.height());
const beforeZoom = await height();
await p.mouse.move(512, 300);
for (let i = 0; i < 4; i++) {
await p.mouse.wheel({ deltaY: -240 });
await new Promise((resolve) => setTimeout(resolve, 120));
}
await new Promise((resolve) => setTimeout(resolve, 1500));
const zoomed = await height();
await new Promise((resolve) => setTimeout(resolve, 1500));
const kept = await height();
await strict.check(
p,
'directions',
'CLEAR removes the route and releases the camera: no route motion, the camera stays put, a wheel zoom moves it and is kept',
released.motion === null &&
!released.fly &&
d(still, later) < 1 &&
zoomed < beforeZoom * 0.95 &&
Math.abs(kept - zoomed) < 0.02 * zoomed,
{ released, drift: d(still, later), beforeZoom, zoomed, kept },
);
assert.deepEqual(errors, []);
console.log(
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
);
});
+2 -3
View File
@@ -35,9 +35,8 @@ const check = (name, passed, detail = '') => {
};
const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
headless: false,
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
-305
View File
@@ -1,305 +0,0 @@
import assert from 'node:assert/strict';
import sharp from 'sharp';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
import { fromDegrees } from '../src/geo/cartesian3.js';
const url = localPreview(process.argv[2]);
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l=';
const png = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#fd2233' },
})
.png()
.toBuffer();
const features = [
{
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
},
{
id: 'line',
geometry: {
type: 'LineString',
coordinates: [
[-97.745, 30.268, 400],
[-97.735, 30.268, 400],
],
},
},
{
id: 'polygon',
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.747, 30.264, 450],
[-97.74, 30.264, 450],
[-97.74, 30.267, 450],
[-97.747, 30.264, 450],
],
],
},
},
].map((f) => ({ ...f, type: 'Feature' }));
const wav = Buffer.alloc(44 + 16000);
wav.write('RIFF');
wav.writeUInt32LE(wav.length - 8, 4);
wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24);
wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(16000, 40);
const pack = (id, format, path, placement) => ({
id,
version: 1,
format,
source: { adapter: 'assets', path },
attribution: { text: 'Local fixture', license: 'CC0-1.0' },
placement,
});
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
altitudeReference: 'ellipsoid',
};
const shot = {
id: 'one',
title: 'Pack view',
camera,
durationSec: 5,
holdSec: 3,
visual: { style: 'normal', mapStack: 'sentinel2' },
layers: { earthquakes: true },
dataPackIds: ['data', 'image', 'audio'],
interactions: features.map((f) => ({
id: f.id,
label: 'Show ' + f.id,
target: { packId: 'data', featureId: f.id },
action: { type: 'card', text: f.id },
})),
};
const project = {
version: 6,
scenes: [
{
id: 'parity',
title: 'Test project',
anchors: [
{
id: 'station',
lat: 30.271,
lon: -97.735,
alt: 500,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
pack('data', 'geojson', 'data.json', {
altitudeReference: 'ellipsoid',
}),
pack('image', 'image', 'image.png', {
altitudeReference: 'ellipsoid',
bounds: [-97.738, 30.264, -97.732, 30.267],
height: 500,
}),
pack('audio', 'media', 'audio.wav', { anchorId: 'station' }),
],
shots: [
shot,
{
...shot,
id: 'two',
title: 'Next shot',
camera: { ...camera, heading: 15 },
move: { from: camera, easing: 'linear' },
dataPackIds: [],
interactions: [],
},
],
},
],
};
// Scene Director is not a catalog layer; the scenario answers the scene's
// earthquake feed itself.
const strict = createStrict({
origin: url.origin,
layers: [],
fixtures: ['https://earthquake.usgs.gov/*'],
});
await strict.run({}, async (b) => {
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 900 });
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message);
});
const reads = [];
await p.setRequestInterception(true);
p.on('request', (req) => {
const path = new URL(req.url()).pathname;
if (path.startsWith('/scene-assets/')) {
reads.push(path);
const file = path.split('/').pop();
return req.respond({
status: 200,
contentType:
file === 'data.json'
? 'application/geo+json'
: file === 'audio.wav'
? 'audio/wav'
: 'image/png',
body:
file === 'data.json'
? JSON.stringify({ type: 'FeatureCollection', features })
: file === 'audio.wav'
? wav
: png,
});
}
if (new URL(req.url()).hostname === 'earthquake.usgs.gov')
return req.respond({
status: 200,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: '{"type":"FeatureCollection","features":[]}',
});
return req.continue();
});
await p.goto(url.href);
await p.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.querySelector('#loading-screen.hidden'),
);
await p.evaluate(async (project) => {
const a = window.__godsEyeView;
if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset)
throw Error('Expected keyless preview');
await a.styleManager.initialRestorePromise;
window.director = a.sceneDirector;
await window.director.importProjectFile(
new File([JSON.stringify(project)], 'qa.json'),
);
const out = await window.director.loadShot('parity', 'one', {
flyDuration: 0.2,
});
if (!out.started)
throw Error(
'Pack load failed: ' +
JSON.stringify(out) +
' ' +
window.director._presentation.status,
);
}, project);
await p.waitForSelector('[data-director-pack="audio"] audio');
await p.evaluate(() => {
window.qaAudio = document.querySelector(
'[data-director-pack="audio"] audio',
);
});
const at = (world) =>
p.evaluate((world) => {
const a = window.__godsEyeView;
const xy = a.engine.projector.worldToWindow(world),
rect = a.engine.host.canvas.getBoundingClientRect();
return { x: xy.x + rect.x, y: xy.y + rect.y };
}, world);
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
await strict.switchTo(p, kind);
await new Promise((r) => setTimeout(r, 500));
const st = await p.evaluate(() => ({
same: window.director === window.__godsEyeView.sceneDirector,
media:
window.qaAudio ===
document.querySelector('[data-director-pack="audio"] audio'),
packs: window.director.getDataPackState(),
actions: window.director.getInteractionState(),
}));
assert.ok(
st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
);
const xy = await at(fromDegrees(-97.742, 30.27, 450));
await p.mouse.click(xy.x, xy.y);
await p.waitForFunction(
() => document.activeElement?.dataset.directorAction === 'point',
);
await p.keyboard.press('Enter');
await p.waitForFunction(
() => window.director.getInteractionState().selected === 'point',
);
for (const [id, coords] of [
['line', [-97.74, 30.268, 400]],
['polygon', [-97.742, 30.265, 450]],
]) {
const hit = await at(fromDegrees(...coords));
await p.mouse.click(hit.x, hit.y);
await p.waitForFunction(
(id) => document.activeElement?.dataset.directorAction === id,
{ timeout: 10000 },
id,
);
}
const imageAt = await at(fromDegrees(-97.735, 30.2655, 500));
const sample = await sharp(await p.screenshot())
.extract({
left: Math.round(imageAt.x),
top: Math.round(imageAt.y),
width: 1,
height: 1,
})
.removeAlpha()
.raw()
.toBuffer();
console.log(kind, st.packs.count, st.actions.count, Array.from(sample));
assert.ok(
sample[0] > 100 && sample[0] > sample[1] * 2,
'authored image really draws',
);
assert.equal(
reads.length,
3,
'packs are not acquired again on a globe change',
);
}
await p.evaluate(() => {
window.qaRun = window.director.startScene('parity', {
single: true,
preview: false,
});
});
await p.waitForFunction(
() =>
window.director.running &&
window.director.getPlaybackTimingState().snapshot?.shotId === 'one',
);
await strict.switchTo(p, 'cesium');
assert.ok(
await p.evaluate(
() =>
window.director.running &&
window.director === window.__godsEyeView.sceneDirector,
),
);
await p.waitForFunction(
() => window.director.getPlaybackTimingState().snapshot?.shotId === 'two',
{ timeout: 20000 },
);
await strict.switchTo(p, 'navara');
const complete = await p.evaluate(() => window.qaRun);
assert.ok(complete.started && complete.shots === 2);
assert.deepEqual(errors, []);
console.log(
'PASS authored packs, geographic picks, same player, original multi-shot timeline',
);
});
-468
View File
@@ -1,468 +0,0 @@
import assert from 'node:assert/strict';
import sharp from 'sharp';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?welcome=0&look=cinematic';
base.hash =
'v=2&lat=42.3554&lon=-71.0605&alt=6000&heading=0&pitch=-90&map=osm&l=e';
const strict = createStrict({ origin: base.origin, layers: ['earthquakes'] });
await strict.run({}, async (browser) => {
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
const fetch = window.fetch.bind(window);
// Two Boston vessels for the Contacts session; empty air and site feeds.
const fixtures = {
'/api/vessels': () => ({
status: 'live',
rows: ['123456789', '223456789'].map((mmsi, i) => ({
mmsi,
name: 'Boston ' + i,
lat: 42.3554,
lon: -71.0605 + i * 0.05,
last_position_epoch: Date.now() / 1000,
})),
}),
'/api/vessels/track': () => ({ samples: [] }),
'/api/flights': () => ({ time: Date.now() / 1000, states: [] }),
'/api/military': () => ({ now: Date.now(), ac: [] }),
'/api/military-installations': () => ({ elements: [], status: 'ok' }),
};
window.fetch = (url, options) => {
if (
String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/')
)
return Promise.resolve(
Response.json({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'qa-boston',
geometry: {
type: 'Point',
coordinates: [-71.0605, 42.3554, 8],
},
properties: { mag: 4, time: Date.now(), place: 'QA Boston' },
},
],
}),
);
const fixture =
fixtures[new URL(url.url ?? url, document.baseURI).pathname];
return fixture
? Promise.resolve(Response.json(fixture()))
: fetch(url, options);
};
});
await page.setViewport({ width: 1280, height: 800 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
async function ready(kind) {
await page.waitForFunction(
(kind) =>
window.__godsEyeView?.engine?.kind === kind &&
document.querySelector('#globe-engine')?.value === kind &&
!document.querySelector('#globe-engine').disabled &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
kind,
);
await page.evaluate(async () => {
await window.__godsEyeView.styleManager.initialRestorePromise;
if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset)
throw new Error('Keyless preview required');
});
assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind);
}
async function verifyQuakeSurface(kind) {
const meanRed = async (enabled) => {
await page.evaluate(async (enabled) => {
const a = window.__godsEyeView;
await a.dataManager.setEnabled('earthquakes', enabled, {
origin: 'user',
});
a.engine.render.request('qa-quake-surface');
}, enabled);
if (enabled)
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('earthquakes')
.module.getAnalystRecords().length === 1,
);
await new Promise((resolve) => setTimeout(resolve, 1800));
const bytes = await page.screenshot({
clip: { x: 675, y: 403, width: 20, height: 20 },
});
const { channels } = await sharp(bytes).stats();
return channels[0].mean - channels[1].mean;
};
const off = await meanRed(false),
on = await meanRed(true);
assert.ok(
on > off + 8,
kind +
' must draw the shallow red earthquake footprint: ' +
off +
' -> ' +
on,
);
await strict.passed(page, 'earthquakes', 'shallow red footprint drawn', {
off,
on,
});
console.log(
'PASS',
kind,
'earthquake footprint red minus green:',
off,
'->',
on,
);
}
const stored = () => page.evaluate(() => localStorage.getItem('gev.engine'));
async function switchTo(kind) {
if (
await page.$eval(
'#control-panel-toggle',
(el) => el.getAttribute('aria-expanded') !== 'true',
)
) {
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
}
await page.waitForSelector('#globe-engine', { visible: true });
await page.evaluate(() => {
window.__switchDocument = document;
window.__quakeEntry =
window.__godsEyeView.dataManager.layers.get('earthquakes');
window.__quakeTimer = window.__quakeEntry.intervalId;
window.__renderers ??= new Map();
window.__renderers.set(
window.__godsEyeView.engine.kind,
window.__godsEyeView.engine,
);
window.__switchSplash = false;
window.__switchObserver = new MutationObserver(() => {
if (!document.querySelector('#loading-screen.hidden'))
window.__switchSplash = true;
});
window.__switchObserver.observe(
document.querySelector('#loading-screen'),
{ attributes: true },
);
window.__outgoingEngine = window.__godsEyeView.engine;
});
await strict.pick(page, kind);
await ready(kind);
assert.ok(await page.evaluate(() => window.__switchDocument === document));
assert.ok(
await page.evaluate(() => {
window.__switchObserver.disconnect();
const { engine } = window.__godsEyeView;
const previous = window.__renderers.get(engine.kind);
return (
!window.__switchSplash &&
window.__quakeEntry ===
window.__godsEyeView.dataManager.layers.get('earthquakes') &&
window.__quakeTimer === window.__quakeEntry.intervalId &&
(!previous || previous === engine) &&
!window.__outgoingEngine.isDestroyed() &&
!window.__outgoingEngine.host.canvas.isConnected &&
engine.host.canvas.isConnected &&
window.__godsEyeView.dataManager
.getEnabledLayerIds()
.has('earthquakes')
);
}),
);
await strict.passed(
page,
'earthquakes',
'same layer entry, model and poll timer; no splash; outgoing canvas detached',
);
await page.waitForFunction(
() => !document.querySelector('.engine-switch-frame'),
);
assert.equal(
await page.evaluate(async () => {
const parked = window.__outgoingEngine;
let frames = 0;
const remove = parked.frames.postRender.addEventListener(() => {
frames++;
});
parked.render.request('qa-parked');
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
remove();
return frames;
}),
0,
);
const url = new URL(page.url());
assert.equal(url.searchParams.get('engine'), kind);
assert.equal(url.searchParams.get('look'), 'cinematic');
const hash = new URLSearchParams(url.hash.slice(1));
assert.equal(hash.get('map'), 'osm');
assert.ok(Math.abs(Number(hash.get('lat')) - 42.3554) < 0.01);
assert.ok(Math.abs(Number(hash.get('lon')) + 71.0605) < 0.01);
assert.ok(Math.abs(Number(hash.get('alt')) - 6000) < 30);
const view = await page.evaluate(() => {
const { engine, mapStackController } = window.__godsEyeView;
const { latitude, longitude, height } = engine.camera.pose().cartographic;
return {
latitude: latitude * (180 / Math.PI),
longitude: longitude * (180 / Math.PI),
height,
map: mapStackController.getActiveId(),
};
});
assert.equal(view.map, 'osm');
assert.ok(Math.abs(view.latitude - 42.3554) < 0.01);
assert.ok(Math.abs(view.longitude + 71.0605) < 0.01);
assert.ok(Math.abs(view.height - 6000) < 30);
await strict.passed(
page,
'earthquakes',
'parked engine draws no frame; pose, map and layers carried over',
);
}
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
await ready('cesium');
await verifyQuakeSurface('cesium');
const sharpen = () =>
page.evaluate(
() => window.__godsEyeView.styleManager.getControlState().sharpen,
);
await page.evaluate(() => {
const style = window.__godsEyeView.styleManager;
style.setStyle('retro');
style.setStyle('normal');
});
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 49 });
await switchTo('navara');
await verifyQuakeSurface('navara');
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 80 });
assert.ok(
await page.evaluate(
() =>
Math.abs(window.__godsEyeView.engine.render.sharpen().amount - 1.7) <
1e-6,
),
);
assert.equal(await stored(), 'navara');
await page.goBack({ waitUntil: 'domcontentloaded' });
await ready('cesium');
assert.equal((await sharpen()).intensityPct, 49);
await page.goForward();
await ready('navara');
assert.equal((await sharpen()).intensityPct, 80);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setSharpen({
enabled: false,
intensityPct: 49,
}),
);
await switchTo('cesium');
await switchTo('navara');
assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 });
// The address carries the running layer set once its debounced write lands.
const settledAddress = async () => {
let previous;
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 1200));
const current = await page.evaluate(() =>
new URLSearchParams(location.hash.slice(1)).get('l'),
);
if (current === previous) return current;
previous = current;
}
};
const pick = async (kind) => {
await strict.pick(page, kind);
await ready(kind);
await page.waitForFunction(
() => !document.querySelector('.engine-switch-frame'),
);
};
const visit = async (direction, kind) => {
await (direction === 'back'
? page.goBack({ waitUntil: 'domcontentloaded' })
: page.goForward());
await ready(kind);
await page.waitForFunction(
() => !document.querySelector('.engine-switch-frame'),
);
};
const quakes = () =>
page.evaluate(() => {
const { dataManager } = window.__godsEyeView;
return {
model: window.__quakeModel === dataManager.layers.get('earthquakes'),
enabled: dataManager.getEnabledLayerIds().has('earthquakes'),
};
});
// Like a same-engine Back, each entry brings back its own layer set, on
// the same layer models.
await page.evaluate(() => {
window.__quakeModel =
window.__godsEyeView.dataManager.layers.get('earthquakes');
});
const navaraLayers = await settledAddress();
await pick('cesium');
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('earthquakes', false, {
origin: 'user',
}),
);
const cesiumLayers = await settledAddress();
assert.notEqual(cesiumLayers, navaraLayers);
await visit('back', 'navara');
assert.deepEqual(await quakes(), { model: true, enabled: true });
await strict.passed(
page,
'earthquakes',
'Back restores the enabled layer on the same model',
);
assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry');
await visit('forward', 'cesium');
assert.deepEqual(await quakes(), { model: true, enabled: false });
await strict.passed(
page,
'earthquakes',
'Forward restores the disabled layer on the same model',
);
assert.equal(await settledAddress(), cesiumLayers, 'Forward keeps its entry');
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('earthquakes', true, {
origin: 'user',
}),
);
await pick('navara');
console.log(
"PASS Back/Forward restore each entry's layers on the same models",
);
// Back/Forward hand the session off like the picker: the Contacts subject
// and its navigation history, its layers and the Director survive.
await page.evaluate(async () => {
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
await dataManager.setEnabled('ais-live-vessels', true, {
origin: 'user',
});
const outcome = await styleManager.setContextMode('contacts');
if (!outcome.ok) throw Error(JSON.stringify(outcome));
window.qaSession = {
director: sceneDirector,
context: dataManager.layers.get('military-awareness').module,
vessels: dataManager.layers.get('ais-live-vessels'),
};
if (!window.qaSession.vessels.module.selectById('123456789'))
throw Error('Missing first vessel');
if (
!window.qaSession.context.navigateNext({
targetLayer: 'ais-live-vessels',
})
)
throw Error('Cannot navigate to the second vessel');
});
const session = () =>
page.evaluate(() => {
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
const { director, context, vessels } = window.qaSession;
return {
director: director === sceneDirector && !director._destroyed,
owners:
context === dataManager.layers.get('military-awareness').module &&
vessels === dataManager.layers.get('ais-live-vessels'),
mode: styleManager.getContextModeState().mode,
subject: context.getContextSnapshot()?.subject?.id,
history: context._getAwarenessNavigationStateForTest().historyKeys,
enabled: [...dataManager.getEnabledLayerIds()].sort(),
};
});
const live = await session();
assert.equal(live.mode, 'flights');
assert.equal(live.subject, '223456789');
assert.ok(live.history.includes('ais-live-vessels:123456789'));
await settledAddress();
const historyLength = await page.evaluate(() => history.length);
await pick('cesium');
assert.deepEqual(await session(), live, 'a pick keeps the session');
await settledAddress();
await visit('back', 'navara');
assert.deepEqual(await session(), live, 'Back keeps the session');
await visit('forward', 'cesium');
assert.deepEqual(await session(), live, 'Forward keeps the session');
assert.equal(await page.evaluate(() => history.length), historyLength + 1);
assert.ok(
await page.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
const result = await styleManager.setContextMode('off');
await dataManager.setEnabled('ais-live-vessels', false, {
origin: 'user',
});
return result.ok;
}),
);
await pick('navara');
console.log(
'PASS Back/Forward keep Contacts subject, history, layers and Director',
);
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
await ready('navara');
await switchTo('cesium');
assert.equal(await stored(), 'cesium');
await page.evaluate(() => {
const write = Storage.prototype.setItem;
Storage.prototype.setItem = function (key, value) {
if (key === 'gev.engine')
throw new DOMException('Blocked', 'SecurityError');
return write.call(this, key, value);
};
});
await switchTo('navara');
assert.equal(await stored(), 'cesium');
// The real Map Source chips move into daylight inspection and back to live lighting.
await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click());
await page.waitForFunction(
() =>
window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' &&
window.__godsEyeView.engine.host.container.hasAttribute(
'data-terrain-daylight',
),
);
// Zooming out on the satellite map must keep the same inspection sun.
assert.ok(
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
engine.camera.zoomOut(2_000_000);
await new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
engine.render.request('qa-orbital-daylight');
});
return (
engine.camera.height() > 1_500_000 &&
engine.host.container.hasAttribute('data-terrain-daylight')
);
}),
'the orbital frame keeps daylight on the inspected globe',
);
await page.$eval('[data-stack-id="osm"]', (el) => el.click());
await page.waitForFunction(
() =>
window.__godsEyeView.mapStackController.getActiveId() === 'osm' &&
!window.__godsEyeView.engine.host.container.hasAttribute(
'data-terrain-daylight',
),
);
assert.deepEqual(errors, []);
console.log(
'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage',
);
});
+44 -75
View File
@@ -28,15 +28,15 @@
* E3 requests : the sweep requested all 12 hexes, each exactly once
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
* enrichment (expected: 8)
* E5 glyphs : exact classified silhouette at its registered 64/192 px raster
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
* drip), total ≤300 (budget ceiling)
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
* stop at the real 300-token initial ceiling; the QA seam
* shortens only the refill window (a real 5-min window
* cannot be waited out headlessly)
* stop being requested once the bucket is empty (shrunk via
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
* be waited out headlessly)
* E11 resume : after a refill window passes, ambient enrichment RESUMES
* for the still-unrequested planes (the 2026-07-03 field bug:
* the old one-shot session cap never refilled, so hours-long
@@ -66,14 +66,9 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const BASE_URL = getOpt('--url', 'http://localhost:4173');
// --engine=cesium|navara: Navara reads the layer through the QA
// probe (window.__godsEyeView.layers.drawn) and moves the engine camera.
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
const APP_URL =
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/enrich-ambient' : `qa-shots/enrich-ambient-${ENGINE}`);
const SHOT_DIR = path.resolve('qa-shots/enrich-ambient');
// Client-side bounds under test (flights.js constants):
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
@@ -81,13 +76,13 @@ const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
// Rolling-budget QA seam: exercise the real initial ceiling and shorten
// only the refill window so the exhaust→refill cycle is observable. windowMs starts
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
// land mid-phase); E11 then shortens it in-page so refill windows have
// "passed" and the bucket refills (clamped at ceil). The initial production
// ceiling of 300 is exercised without assuming the override resets state.
const BUDGET_QA = { ceil: SESSION_CAP, refillTokens: 6, windowMs: 3600000 };
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
const CHROME_EXECUTABLE_CANDIDATES = [
@@ -165,9 +160,6 @@ const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
]));
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
// Adaptive raster resolution preserves the classified silhouette. Compare
// exact registered assets at either supported resolution, never another class.
const matchesClassIcon = (image, klass) => image === aircraftIcon(klass) || image === aircraftIcon(klass, 192);
// ---------------------------------------------------------------------------
async function main() {
@@ -193,7 +185,7 @@ async function main() {
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
@@ -216,7 +208,6 @@ async function main() {
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
await page.evaluateOnNewDocument((spec) => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__ENR = spec;
window.__ENR.epochMs = Date.now();
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
@@ -298,24 +289,15 @@ async function main() {
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
{ timeout: 60000, polling: 200 }
);
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
console.log(' App globals ready.');
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
await page.evaluate(() => {
window.__collectBillboards = function () {
const v = window.__godsEyeView.viewer;
// Without a viewer (Navara): what the flights layer draws, through
// the engine-neutral probe.
if (!v)
return (window.__godsEyeView.layers.drawn('flights') ?? [])
.filter((row) => !row.trackedModel)
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
const out = [];
const walk = (coll) => {
const n = coll.length;
@@ -339,12 +321,12 @@ async function main() {
// framing the fleet when the layer polls — otherwise the frustum test
// (correctly) skips off-screen planes.
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
await page.evaluate(async ({ lat, lon, height }) => {
const camera = window.__godsEyeView.engine.camera;
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
const geo = await import('/src/geo/index.js');
camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
await page.evaluate(({ lat, lon, height }) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
const C3 = v.camera.position.constructor;
v.camera.setView({
destination: C3.fromDegrees(lon, lat, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
@@ -352,9 +334,8 @@ async function main() {
console.log('Priming straight-flight history through the render delay (30 s)...');
const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer ?? null;
const v = window.__godsEyeView.viewer;
window.__ENR.timeOffsetSec = -32;
dm.setLayerParams('flights', { models3d: false }); // ambient budget excludes model-priority enrichment
await dm.setEnabled('flights', true);
const fl = dm.layers.get('flights').module;
for (const off of [-24, -16, -8, 0]) {
@@ -363,11 +344,11 @@ async function main() {
}
window.__ENR.timeOffsetSec = 0;
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length, models3d: fl.getParams().models3d };
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
});
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
record('E1 ingest: all synthetic category-0 planes in the flights layer',
primed.count === SPEC.planes.length && primed.models3d === false, `count=${primed.count}/${SPEC.planes.length}, ambient-only models3d=${primed.models3d}`);
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
if (primed.count === 0) { finish(); return; }
// ========================================================================
@@ -433,11 +414,12 @@ async function main() {
const scaleBad = [];
for (const [hex, klass] of expected) {
const bb = afterById.get(hex);
const wantImg = aircraftIcon(klass);
const wantScale = CLASS_SCALE_2D[klass] || 1;
if (!bb || !matchesClassIcon(bb.image, klass)) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
}
record('E5 glyphs: every billboard matches its classified 64/192 px asset',
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
@@ -474,7 +456,7 @@ async function main() {
console.log('\nE8 — next poll after enrichment');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer ?? null;
const v = window.__godsEyeView.viewer;
await dm.layers.get('flights').module.update(v);
});
await sleep(500);
@@ -486,7 +468,7 @@ async function main() {
const pollBad = [];
for (const [hex, klass] of expected) {
const bb = afterPollById.get(hex);
if (!bb || !matchesClassIcon(bb.image, klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
}
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
@@ -498,19 +480,20 @@ async function main() {
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
// old ONE-SHOT session cap burned out and never refilled, so every new
// region after the first polls showed airliner monoculture. With the seam
// initial ceiling=300 and 12 tokens spent above, 288 remain. More than
// 300 total candidates must stall at exactly 300 requests; further polls
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
// on-screen planes must stall at exactly 8 new requests, and further polls
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
// no refill can land mid-phase). E10 verifies the initial limit; E11
// distinguishes a rolling refill from the old one-shot session cap.
// no refill can land mid-phase). Pre-fix code fails this phase: there is
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
// admits the whole batch).
// ========================================================================
console.log('\nE10 — rolling budget exhausts (real 300-request initial bucket)');
const BATCH_COUNT = BUDGET_QA.ceil + 26;
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => (0xab0000 + i + 1).toString(16).padStart(6, '0'));
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
const BATCH_COUNT = 26;
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer ?? null;
const v = window.__godsEyeView.viewer;
// Two fresh rows just north of the original one — still inside the
// top-down 20 km frame, so the sweep's frustum test keeps them.
// STATIONARY (speed 0): moving planes drift north out of the frustum at
@@ -523,47 +506,33 @@ async function main() {
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
courseDeg: 0,
speedMps: 0,
altM: 2600 + (i % 26) * 15,
altM: 2600 + i * 15,
});
});
const geo = await import('/src/geo/index.js');
const engine = window.__godsEyeView.engine;
engine.camera.cancelFlight();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(rowLon0 + 5.5 * stepDeg, rowLat, 20000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
for (let frame = 0; frame < 30; frame++) {
engine.render.request('qa-enrich-budget');
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const fl = dm.layers.get('flights').module;
await fl.update(v); // sweep enqueues up to 150 ambient jobs
await fl.update(v); // spend the remaining initial tokens; later polls must stall
await fl.update(v); // sweep enqueues the last 8 tokens
await fl.update(v); // bucket empty — must enqueue nothing more
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
// Wait for the drip to drain whatever was enqueued, then settle.
await page.waitForFunction(
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
{ timeout: 90000, polling: 100 }, BUDGET_QA.ceil
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
).catch(() => { /* asserted below */ });
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
// One more poll while exhausted — still nothing new.
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
});
await sleep(1000);
const exhausted = await page.evaluate(() => ({
starts: window.__ENRICH_LOG.starts.length,
inflight: window.__ENRICH_LOG.inflight,
sourceCount: window.__godsEyeView.dataManager.layers.get('flights').module.getStats().count,
fixtureCount: window.__ENR.planes.length,
cameraHeightM: window.__godsEyeView.engine.camera.pose().cartographic.height,
}));
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0 && exhausted.sourceCount === startsBeforeBatch + BATCH_COUNT,
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}, source=${exhausted.sourceCount}/${exhausted.fixtureCount}, camera=${exhausted.cameraHeightM.toFixed(1)}m`);
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
// ========================================================================
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
@@ -575,7 +544,7 @@ async function main() {
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
});
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
const resumed = await page.waitForFunction(
-225
View File
@@ -1,225 +0,0 @@
import assert from 'node:assert/strict';
import { installAlprFixture, cameras } from './fixtures/w2/alpr.mjs';
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/w2/firms.mjs';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]),
fire = process.argv.includes('--fires'),
id = fire ? 'local-firms' : 'alpr-cameras';
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&' +
(fire
? 'lat=37.77&lon=-122.42&alt=100000'
: 'lat=30.262&lon=-97.7431&alt=5500') +
'&pitch=-90&heading=0&map=sentinel2&l=';
// Records the fixtures produce: the valid FIRMS rows; the ALPR cameras the
// fixture tiles carry around Austin.
const COUNT = fire ? FIRMS_VALID_COUNT : cameras.length;
const strict = createStrict({
origin: url.origin,
layers: [id],
// installAlprFixture answers the camera tiles.
fixtures: ['https://tiles.dontgetflocked.com/*'],
});
await strict.run({}, async (b) => {
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 900 });
const errors = [];
p.on('pageerror', (e) => errors.push(e.message));
await installAlprFixture(p);
await p.evaluateOnNewDocument((data) => {
window.qaFirmsReads = 0;
const fetch = window.fetch.bind(window);
window.fetch = (input, opt) => {
if (
new URL(input.url ?? input, location.href).pathname === '/api/firms'
) {
window.qaFirmsReads++;
return Promise.resolve(Response.json(data));
}
return fetch(input, opt);
};
}, firmsFixture());
await p.goto(url.href);
await p.waitForFunction(
() =>
window.__godsEyeView?.engine?.kind === 'navara' &&
document.querySelector('#loading-screen.hidden'),
);
await p.evaluate(async (id) => {
const { dataManager, styleManager, tileset } = window.__godsEyeView;
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
throw Error('Keyless preview required');
await styleManager.initialRestorePromise;
window.qaGround = dataManager.layers.get(id).module;
await dataManager.setEnabled(id, true, { origin: 'user' });
}, id);
await p.waitForFunction(() => window.qaGround?.getStats().count > 0, {
timeout: 30000,
});
// A pickable target in the middle of the view: the camera the engine's
// own picker returns, or the hottest fire.
const findTarget = () =>
p
.waitForFunction(
(fire) => {
const a = window.__godsEyeView,
m = window.qaGround;
const points = fire
? m
.getDetectableObjects({ maxCount: 1000 })
.filter((v) => v.id === m.getAnalystRecords(1)[0]?.id)
: [...window.__gevContextStore.entities.values()]
.filter((v) => v.layerId === 'alpr-cameras')
.map((v) => ({
id: v.id,
position:
v.entity.gevAlprCanvasPosition ||
v.entity.gevDisplayPosition?.(),
}));
for (const row of points) {
if (!row.position) continue;
const s = a.engine.projector.worldToWindow(row.position);
if (!s || s.x < 430 || s.x > 850 || s.y < 220 || s.y > 600)
continue;
const canvas = a.engine.host.canvas;
const box = canvas.getBoundingClientRect();
if (
!canvas.contains(
document.elementFromPoint(box.left + s.x, box.top + s.y),
)
)
continue;
const picked = a.engine.picker.pick(s);
if (fire || picked?.id === row.id || picked?.id?.id === row.id)
return { x: box.left + s.x, y: box.top + s.y, id: row.id };
}
return false;
},
{ timeout: 30000 },
fire,
)
.then((handle) => handle.jsonValue());
const selected = (id) =>
p.waitForFunction(
(id, fire) =>
fire
? window.qaGround.getSelectedInfo()?.frp === 999
: window.__gevContextStore?.selectedEntityId === id,
{ timeout: 15000 },
id,
fire,
);
// A click on empty ground (nothing picked there, nothing covering it).
const clickEmpty = async () => {
const handle = await p.waitForFunction(
() => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (let y = 240; y < 600; y += 9)
for (let x = 440; x < 840; x += 9) {
if (
!canvas.contains(
document.elementFromPoint(box.left + x, box.top + y),
)
)
continue;
let free = true;
for (const [dx, dy] of [
[0, 0],
[6, 0],
[-6, 0],
[0, 6],
[0, -6],
])
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
if (free) return { x: box.left + x, y: box.top + y };
}
return false;
},
{ timeout: 15000 },
);
const at = await handle.jsonValue();
await p.mouse.click(at.x, at.y);
await p.waitForFunction(
(fire) =>
fire
? !window.qaGround.getSelectedInfo()
: !window.__gevContextStore?.selectedEntityId,
{ timeout: 15000 },
fire,
);
};
const xy = await findTarget();
await new Promise((r) => setTimeout(r, 1000));
await p.mouse.click(xy.x, xy.y);
await selected(xy.id);
await strict.passed(
p,
id,
fire
? 'a click on the hottest fire selects it'
: 'a click on a camera selects it',
xy,
);
await new Promise((r) => setTimeout(r, 1600));
const expectedId = fire
? await p.evaluate(() => window.qaGround.getSelectedInfo().id)
: xy.id;
const before = await p.evaluate(() => ({
stamp: window.qaGround.getStats().lastUpdate,
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
reads: window.qaFirmsReads,
}));
for (const kind of ['cesium', 'navara']) {
await strict.switchTo(p, kind);
const result = await p.evaluate(
(id) => ({
same:
window.qaGround ===
window.__godsEyeView.dataManager.layers.get(id).module,
selected: window.__gevContextStore.selectedEntityId,
record: window.qaGround.getSelectedInfo?.()?.id,
stamp: window.qaGround.getStats().lastUpdate,
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
reads: window.qaFirmsReads,
count: window.qaGround.getStats().count,
}),
id,
);
console.log(kind, id, result.selected, result.count, result.records.length);
assert.ok(result.same && result.count);
assert.equal(result.selected, expectedId);
if (fire) assert.equal(result.record, expectedId);
assert.equal(result.reads, before.reads);
assert.deepEqual(result.records, before.records);
assert.equal(result.stamp, before.stamp);
await strict.check(
p,
id,
`same model, ${COUNT} records, the clicked record still selected, no source read, same receipt time`,
result.count === COUNT,
{ count: result.count },
);
if (kind === 'cesium') {
// Click-picking on Cesium: clear the selection with a click on empty
// ground, then click the same record again.
await clickEmpty();
const again = await findTarget();
assert.equal(again.id, xy.id);
await p.mouse.click(again.x, again.y);
await selected(again.id);
await strict.passed(
p,
id,
fire
? 'a click on empty ground clears the fire; a click on it selects it again'
: 'a click on empty ground clears the camera; a click on it selects it again',
again,
);
}
}
assert.deepEqual(errors, []);
console.log('PASS retained source, stable clicked record on each planet');
});
+16 -73
View File
@@ -30,36 +30,22 @@
*/
import puppeteer from 'puppeteer';
import {
isExpectedPreviewSetupError,
redact,
} from './qa-browserEvidence.mjs';
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 SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const appUrl = new URL(getOpt('--url', 'http://localhost:4420'));
const ENGINE = getOpt('--engine', appUrl.searchParams.get('engine') || 'cesium');
if (!['cesium', 'navara'].includes(ENGINE)) throw new Error('Unknown engine');
appUrl.searchParams.set('engine', ENGINE);
const APP_URL = appUrl.href;
const APP_URL = getOpt('--url', 'http://localhost:4420');
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const PREVIEW = argv.includes('--preview');
const ANGLE = getOpt('--angle', 'swiftshader');
const OUT = getOpt('--out', null);
const SHOTS_DIR = OUT ? path.resolve(OUT).replace(/\.json$/, '') + '-shots' : path.join(REPO_ROOT, 'qa-shots');
if (!['metal', 'swiftshader'].includes(ANGLE)) throw new Error('Unknown ANGLE backend');
const runtimeErrors = [];
const expectedSourceErrors = [];
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -171,11 +157,9 @@ async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
const actualEngine = await page.evaluate(() => window.__godsEyeView.engine.kind);
if (actualEngine !== ENGINE) throw new Error('Requested FIRMS engine did not load');
await sleep(5000);
await page.keyboard.press('Escape');
return page.evaluate(async (tS) => {
@@ -194,15 +178,8 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
async function setView(page, lon, lat, height) {
await page.evaluate(async (lo, la, h) => {
await page.evaluate((lo, la, h) => {
const gev = window.__godsEyeView;
if (!gev.viewer) {
const geo = await import('/src/geo/index.js');
gev.engine.camera.cancelFlight();
gev.engine.camera.setView({destination:geo.cartesian3.fromDegrees(lo,la,h),orientation:{heading:0,pitch:-Math.PI/2,roll:0}});
gev.engine.render.request('firms-legacy-view');
return;
}
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight.
@@ -274,7 +251,7 @@ async function main() {
headless: HEADFUL ? false : 'new',
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', `--use-angle=${ANGLE}`,
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
'--disable-dev-shm-usage', '--disable-web-security',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1440,900',
@@ -288,23 +265,6 @@ async function main() {
await page.setRequestInterception(true);
let interceptMode = FIXTURES ? 'fixture' : 'live';
page.on('pageerror', error => runtimeErrors.push(redact(error.message)));
page.on('error', error => runtimeErrors.push(redact(error.message)));
page.on('console', message => {
if (message.type() !== 'error') return;
if (isExpectedPreviewSetupError(message, { preview: PREVIEW, baseUrl: APP_URL })) {
expectedSourceErrors.push('/api/setup/status: 404');
return;
}
let expected = false;
try {
const url = new URL(message.location()?.url);
expected = interceptMode === 'keyless' && url.origin === new URL(APP_URL).origin
&& url.pathname === '/api/firms' && /^Failed to load resource:.*\b503\b/.test(message.text());
} catch { /* an unknown location is never waived */ }
if (expected) expectedSourceErrors.push('/api/firms: intentional keyless fixture503');
else runtimeErrors.push(redact(message.text()));
});
const stableStalePayload = stalePayload();
page.on('request', (req) => {
const url = req.url();
@@ -426,22 +386,17 @@ async function main() {
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
if (!target?.position) return null;
const native = Boolean(gev.viewer);
const sentinel = native ? gev.viewer.entities.add({
const sentinel = gev.viewer.entities.add({
id: 'qa-firms-prior-camera-owner',
position: target.position,
}) : gev.engine.camera.follow({position:()=>target.position,viewFrom:{x:0,y:-5000,z:8000},
label:{id:'qa-firms-prior-camera-owner',model:{title:'QA prior camera owner'},position:()=>target.position}});
if (native) gev.viewer.trackedEntity = sentinel;
});
gev.viewer.trackedEntity = sentinel;
const camera = native ? gev.viewer.camera : gev.engine.camera;
const flightMethod = native ? 'flyToBoundingSphere' : 'flyToSphere';
const originalFly = camera[flightMethod];
const camera = gev.viewer.camera;
const originalFly = camera.flyToBoundingSphere;
const proof = {
sentinel,
originalFly,
flightMethod,
native,
flightCount: 0,
trackedAtFlight: 'not-called',
request: null,
@@ -454,9 +409,9 @@ async function main() {
};
};
window.addEventListener('gev:world-request-focus', proof.onRequest);
camera[flightMethod] = function qaFirmsObservedFlight(...args) {
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
proof.flightCount += 1;
proof.trackedAtFlight = native ? gev.viewer.trackedEntity?.id || null : gev.engine.camera.following()?.label?.id || null;
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
return originalFly.apply(this, args);
};
window.__qaFirmsActionProof = proof;
@@ -478,7 +433,7 @@ async function main() {
for (let i = 0; i < 30; i += 1) {
const settled = await page.evaluate(() => {
window.__godsEyeView?.engine?.render?.request?.('firms-legacy');
window.__godsEyeView?.viewer?.scene?.requestRender?.();
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
});
if (settled) break;
@@ -496,7 +451,7 @@ async function main() {
const result = {
flightCount: state?.flightCount || 0,
trackedAtFlight: state?.trackedAtFlight,
trackingReleased: gev.viewer ? gev.viewer.trackedEntity == null : gev.engine.camera.following() == null,
trackingReleased: gev.viewer.trackedEntity == null,
generationBefore: state?.generationBefore,
generationAfter: gev.styleManager._navigationGeneration,
request: state?.request || null,
@@ -506,12 +461,9 @@ async function main() {
).length,
};
if (state) {
(state.native ? gev.viewer.camera : gev.engine.camera)[state.flightMethod] = state.originalFly;
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
window.removeEventListener('gev:world-request-focus', state.onRequest);
if (state.sentinel) {
if (state.native) gev.viewer.entities.remove(state.sentinel);
else state.sentinel.stop();
}
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
}
delete window.__qaFirmsActionProof;
return result;
@@ -561,21 +513,12 @@ async function main() {
await browser.close();
}
record('RUNTIME: no unexpected browser errors', runtimeErrors.length === 0,
runtimeErrors.length ? JSON.stringify(runtimeErrors) : `expected source errors=${expectedSourceErrors.length}`);
if (runtimeErrors.length) exitCode = 1;
const pass = results.filter((r) => r.ok === true).length;
const fail = results.filter((r) => r.ok === false).length;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
console.log('─'.repeat(60) + '\n');
if (OUT) {
const file = path.resolve(OUT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ pass:exitCode === 0 && fail === 0, fixture:FIXTURES, angle:ANGLE, engine:ENGINE,
shots:SHOTS_DIR, results, browserErrors:runtimeErrors, expectedSourceErrors }, null, 2) + '\n');
}
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
+6 -36
View File
@@ -48,7 +48,7 @@ const SITE = {
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
};
const ICAO = 'aa0f01';
const OUT_DIR = new URL(APP_URL).searchParams.get('engine') === 'navara' ? 'qa-shots/floorhold/navara' : 'qa-shots/floorhold';
const OUT_DIR = 'qa-shots/floorhold';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
@@ -107,7 +107,7 @@ await page.evaluateOnNewDocument((site, icao) => {
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForFunction(() => window.__godsEyeView?.layers && window.__godsEyeView?.dataManager,
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 150000 });
await sleep(12000); // let the boot fly-to settle before pinning
await page.evaluate(() => {
@@ -116,19 +116,8 @@ await page.evaluate(() => {
await sleep(600); // let the launcher dismissal finish before taking evidence
await page.evaluate((site) => { window.__SITE = site; }, SITE);
const pin = () => page.evaluate(async () => {
const app = window.__godsEyeView;
if (!app.viewer) {
const geo = await import('/src/geo/index.js');
app.engine.camera.cancelFlight();
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
orientation: { heading: 0, pitch: -0.45, roll: 0 },
});
app.engine.camera.moveEnd.raiseEvent();
return;
}
const v = app.viewer;
const pin = () => page.evaluate(() => {
const v = window.__godsEyeView.viewer;
const C = v.camera.positionCartographic.constructor;
v.camera.cancelFlight();
v.camera.setView({
@@ -153,24 +142,7 @@ record('the billboard floor test is explicitly in 2D aircraft mode', billboardMo
/** Reads the contact's rendered height and the rendered mesh beneath it. */
const measure = () => page.evaluate(async (icao) => {
const app = window.__godsEyeView;
if (!app.viewer) {
const geo = await import('/src/geo/index.js');
const bb = app.layers.drawn('flights').find((row) => row.id === icao && !row.trackedModel);
if (!bb?.position) return { error: 'contact billboard not found' };
const carto = geo.cartographic.fromCartesian(bb.position);
let mesh = null;
for (let attempt = 0; attempt < 10 && mesh == null; attempt++) {
app.engine.render.request('floor-hold');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const height = app.engine.surface.sampleHeight(carto);
if (Number.isFinite(height)) mesh = height;
else await new Promise((resolve) => setTimeout(resolve, 1200));
}
return { spriteH: carto.height, meshH: mesh, shown: bb.show === true,
lat: +(carto.latitude * 180 / Math.PI).toFixed(5), lon: +(carto.longitude * 180 / Math.PI).toFixed(5), terrain: { ...window.__TERRAIN } };
}
const v = app.viewer;
const v = window.__godsEyeView.viewer;
const C = v.camera.positionCartographic.constructor;
const sprites = []; const models = []; let bb = null;
const walk = (coll) => {
@@ -229,9 +201,7 @@ async function demTruth(lat, lon) {
if (truth.has(key)) return truth.get(key);
let h = null;
try {
const url = new URL('/api/terrain/heights', APP_URL);
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
const res = await fetch(url);
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
if (res.ok) {
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
if (Number.isFinite(v)) h = v;
+6 -83
View File
@@ -10,12 +10,7 @@ import fs from 'node:fs';
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a
// secondary checkout can verify against its own dev server instead of the default :4173.
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
// --engine=cesium|navara: on Navara the camera, the mesh probes and
// the sprite census go through the engine and the QA probe.
const ENGINE = (process.argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
const APP_URL =
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
// CLI: --lat --lon --floor-min --floor-max (defaults: Austin airport)
const argv = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).filter((x) => x.length === 2).map(([k, v]) => [k.replace(/^--/, ''), Number(v)]));
const SITE = {
@@ -37,13 +32,13 @@ const chrome = [
const browser = await puppeteer.launch({
headless: 'new', executablePath: chrome,
args: ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900 });
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.engine, { timeout: 150000 });
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.viewer, { timeout: 150000 });
await sleep(2000);
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
@@ -51,16 +46,8 @@ await sleep(2000);
// coarse global tiles at ~25 m and the oracle was garbage).
await sleep(12000);
await page.evaluate((site) => { window.__QA_SITE = site; }, SITE);
const camPin = async () => page.evaluate(async () => {
const camPin = async () => page.evaluate(() => {
const v = window.__godsEyeView.viewer;
if (!v) {
const geo = await import('/src/geo/index.js');
const { camera } = window.__godsEyeView.engine;
camera.cancelFlight();
camera.setView({ destination: geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000) });
const c = camera.pose().cartographic;
return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) };
}
const C = v.camera.positionCartographic.constructor;
v.camera.cancelFlight();
v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) });
@@ -76,7 +63,6 @@ await sleep(100000);
console.log('camera at probe time:', JSON.stringify(await camPin()));
const ready = await page.evaluate(() => {
const v = window.__godsEyeView.viewer;
if (!v) return { tilesLoaded: window.__godsEyeView.engine.surface.tilesLoaded() };
const prims = v.scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
@@ -87,60 +73,11 @@ const ready = await page.evaluate(() => {
console.log('tileset ready:', JSON.stringify(ready));
await sleep(8000);
const report = await page.evaluate(async () => {
const report = await page.evaluate(() => {
const gev = window.__godsEyeView;
const v = gev.viewer;
const layer = gev.dataManager.layers.get('flights')?.module;
if (!layer) return { err: 'no flights module' };
if (!v) {
// The engine path: mesh probes through engine.surface, excluding the
// layer's own mover sets; the census from the probe's drawn().
const geo = await import('/src/geo/index.js');
const { engine } = gev;
const center = geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200);
const nearby = layer.getNearby(center, 15000, 60);
const exclude = engine.movers.list().map(({ id }) => id).filter((id) => id.startsWith('flights'));
// The app's own ground floor (the scene's surface services, not a fresh
// import of the module).
const { cachedGroundFloor } = gev.surfaceServices.groundFloor;
const out = [];
// A contact a 3D model draws is measured where the model is (its sprite
// is hidden and keeps the pre-floor height by design, T7).
const drawnRows = new Map((gev.layers.drawn('flights') ?? []).map((row) => [row.id, row]));
const drawnAt = (p) => {
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
return model?.show && model.ready && model.position ? model.position : null;
};
for (const p of nearby) {
const modelAt = drawnAt(p);
const c = geo.cartographic.fromCartesian(modelAt ?? p.position);
const answer = engine.surface.sampleHeightDetailed(
geo.cartographic.fromRadians(c.longitude, c.latitude, 0),
{ exclude },
);
const meshH = answer?.source === 'tiles' && Number.isFinite(answer.height) ? answer.height : null;
out.push({
id: p.id,
renderAltM: +c.height.toFixed(1),
drawnBy: modelAt ? 'model' : 'sprite',
meshM: meshH != null ? +meshH.toFixed(1) : null,
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
// Why a contact sits where it does: the probe's own
// answer (source, tile size) and the coarse DEM floor (the prior).
probe: answer ? { source: answer.source, tileSizeM: answer.tileSizeM ?? null } : null,
demFloorM: cachedGroundFloor(c.latitude * 180 / Math.PI, c.longitude * 180 / Math.PI) ?? null,
});
}
let shown = 0, hidden = 0;
for (const row of gev.layers.drawn('flights') ?? []) {
if (!row.position) continue;
const cc = geo.cartographic.fromCartesian(row.position);
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
if (row.show) shown++; else hidden++;
}
return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden };
}
const ell = v.scene.globe.ellipsoid;
const C = v.camera.positionCartographic.constructor;
const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200));
@@ -164,15 +101,9 @@ const report = await page.evaluate(async () => {
}
};
walk(v.scene.primitives);
const drawnRows = new Map((gev.layers?.drawn('flights') ?? []).map((row) => [row.id, row]));
const drawnAt = (p) => {
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
return model?.show && model.ready && model.position ? model.position : null;
};
const out = [];
for (const p of nearby) {
const modelAt = drawnAt(p);
const c = ell.cartesianToCartographic(modelAt ?? p.position);
const c = ell.cartesianToCartographic(p.position);
const latDeg = c.latitude * 180 / Math.PI, lonDeg = c.longitude * 180 / Math.PI;
let meshH = null;
try {
@@ -182,16 +113,8 @@ const report = await page.evaluate(async () => {
out.push({
id: p.id,
renderAltM: +c.height.toFixed(1),
drawnBy: modelAt ? 'model' : 'sprite',
meshM: meshH != null ? +meshH.toFixed(1) : null,
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
// A contact whose sprite hides under a model that is not (yet) drawn.
model: drawnRows.get(p.icao24 ?? p.id)?.model
? { show: drawnRows.get(p.icao24 ?? p.id).model.show, ready: drawnRows.get(p.icao24 ?? p.id).model.ready }
: null,
spriteShown: drawnRows.get(p.icao24 ?? p.id)?.show ?? null,
// The coarse floor (mesh cell ?? DEM prior) the layer reads here.
demFloorM: gev.surfaceServices?.groundFloor.cachedGroundFloor(latDeg, lonDeg) ?? null,
});
}
// Visibility census (round 6): getNearby only returns contacts a sprite or a
+14 -54
View File
@@ -17,7 +17,6 @@
* --headful drops those flags and is the real-GPU visual sign-off path.
*/
import { redact } from './qa-browserEvidence.mjs';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -30,13 +29,7 @@ const getOpt = (name, fallback = null) => {
};
const hasFlag = (name) => argv.includes(name);
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
if (requestedEngine) {
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
appUrl.searchParams.set('engine', requestedEngine);
}
const APP_URL = appUrl.href;
const APP_URL = getOpt('--url', 'http://localhost:4173');
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
const HEADFUL = hasFlag('--headful');
@@ -132,10 +125,6 @@ async function readTileReadiness(page) {
const activeStack = controller?.getActiveId?.() || null;
const tileset = controller?.googleTileset || gev.tileset || null;
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
if (!gev.viewer) return {
tilesSettled: null, tileSettleApplicable: false, activeStack,
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
};
return {
tilesSettled: !applicable || tileset?.tilesLoaded === true,
tileSettleApplicable: applicable,
@@ -153,10 +142,6 @@ async function awaitTilesSettled(page, scenario) {
// the bootstrap field is retained only as a compatibility fallback.
const tileset = controller?.googleTileset || gev.tileset || null;
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
if (!gev.viewer) return {
tilesSettled: null, tileSettleApplicable: false, activeStack,
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
};
if (!applicable) {
return {
tilesSettled: true,
@@ -181,8 +166,7 @@ async function awaitTilesSettled(page, scenario) {
const deadline = Date.now() + timeoutMs;
try {
while (Date.now() < deadline) {
if (gev.viewer) gev.viewer.scene.render(gev.viewer.clock.currentTime);
else gev.engine.render.request('focus-evidence');
gev.viewer.scene.render(gev.viewer.clock.currentTime);
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
return {
tilesSettled: true,
@@ -219,7 +203,7 @@ async function capture(page, scenario, frame) {
// WebGL surface is paused. Reading the explicitly rendered canvas captures
// that same frame without handing frame ownership back to the browser.
const dataUrl = await page.evaluate(() => (
window.__godsEyeView.engine.host.canvas.toDataURL('image/png')
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
));
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
return { file: out, ...tileReadiness };
@@ -248,14 +232,6 @@ async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
const viewer = gev.viewer;
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
const nowMs = seam.advanceFrameClock(stepMs);
if (!viewer) return new Promise((resolve, reject) => {
const timer = setTimeout(() => { remove(); reject(new Error('Evidence frame timeout')); }, 5000);
const remove = gev.engine.frames.postRender.addEventListener(() => {
remove(); clearTimeout(timer);
setTimeout(() => resolve(nowMs), 0);
});
gev.engine.render.request('focus-evidence');
});
const JulianDate = viewer.clock.currentTime.constructor;
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
@@ -290,7 +266,6 @@ async function installSyntheticFetches(page) {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/flights/track')) return json({ path: [] });
@@ -306,10 +281,9 @@ async function installSyntheticFetches(page) {
async function waitForApp(page) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
{ timeout: 60_000, polling: 200 },
);
await page.evaluate(async () => { window.__focusGeo = await import('/src/geo/index.js'); });
}
async function setBasemap(page, stackId) {
@@ -343,18 +317,7 @@ async function requireEvidenceSeams(page) {
async function setCamera(page, values) {
await page.evaluate(([lon, lat, height, heading, pitch]) => {
const app = window.__godsEyeView;
if (!app.viewer) {
const geo = window.__focusGeo;
app.engine.camera.following()?.stop();
app.engine.camera.cancelFlight();
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: { heading, pitch, roll: 0 },
});
return;
}
const viewer = app.viewer;
const viewer = window.__godsEyeView.viewer;
const ellipsoid = viewer.scene.globe.ellipsoid;
viewer.trackedEntity = undefined;
viewer.camera.cancelFlight?.();
@@ -382,7 +345,7 @@ async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
}
await page.waitForFunction(
() => Boolean(window.__godsEyeView.engine.camera.following()),
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
{ timeout: 10_000 },
);
await advanceEvidenceDuration(page, 400);
@@ -393,8 +356,7 @@ async function readTrackedBasis(page) {
const gev = window.__godsEyeView;
const layer = gev.dataManager.layers.get('flights').module;
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
const pose = gev.engine.camera.pose();
const camera = gev.viewer?.camera ?? { positionWC: pose.position, rightWC: pose.right, upWC: pose.up, frustum: { fovy: pose.fovy } };
const camera = gev.viewer.camera;
if (!target) throw new Error('Tracked target position unavailable');
const toTarget = {
x: target.x - camera.positionWC.x,
@@ -402,7 +364,7 @@ async function readTrackedBasis(page) {
z: target.z - camera.positionWC.z,
};
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
const canvasHeight = gev.engine.host.size().height;
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
return {
target: [target.x, target.y, target.z],
@@ -465,8 +427,8 @@ async function vesselRowsAroundTarget(page) {
const gev = window.__godsEyeView;
const layer = gev.dataManager.layers.get('flights').module;
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
const Cartographic = { fromCartesian: window.__focusGeo.cartographic.fromCartesian };
const ellipsoid = window.__focusGeo.ellipsoid.WGS84;
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const rows = [];
for (let i = 0; i < 12; i += 1) {
const angle = (i / 12) * Math.PI * 2;
@@ -662,9 +624,7 @@ async function browserContext(page, browser) {
},
caveat: HEADFUL
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
: process.env.QA_ANGLE === 'metal'
? 'Headless ANGLE/Metal capture; renderer and DPR recorded above.'
: 'SwiftShader/headless output is relative-only.',
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
};
}
@@ -681,7 +641,7 @@ async function main() {
headless: HEADFUL ? false : 'new',
protocolTimeout: 300000,
args: [
...(HEADFUL ? [] : process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
@@ -695,10 +655,10 @@ async function main() {
const consoleMessages = [];
page.on('console', (message) => {
if (['error', 'warning', 'warn'].includes(message.type())) {
consoleMessages.push({ type: message.type(), text: redact(message.text()) });
consoleMessages.push({ type: message.type(), text: message.text() });
}
});
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: redact(error.message) }));
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
const report = {
generatedAt: new Date().toISOString(),
+21 -291
View File
@@ -1,29 +1,19 @@
#!/usr/bin/env node
/**
* Keyless station-name regression against the real globe and bike-share tool.
* Run with a dev or QA preview server and an explicitly assigned --url.
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
* GBFS documents are synthetic; no operator feed or provider key is required.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
const urlIndex = process.argv.indexOf('--url');
if (urlIndex < 0) throw new Error('An explicit --url is required');
const appUrl = process.argv[urlIndex + 1];
const engineName = new URL(appUrl).searchParams.get('engine') || 'cesium';
const extended = process.argv.includes('--w2');
const reportIndex = process.argv.indexOf('--report');
const reportPath =
reportIndex < 0 ? null : path.resolve(process.argv[reportIndex + 1]);
const shots = reportPath
? reportPath.replace(/\.json$/, '-shots')
: path.resolve(`qa-shots/gbfs-names/${engineName}`);
const appUrl =
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
const shots = path.resolve('qa-shots/gbfs-names');
fs.mkdirSync(shots, { recursive: true });
const report = { engine: engineName, checks: [], poses: {}, browserErrors: [] };
const information = {
version: '3.0',
data: {
@@ -77,7 +67,6 @@ const browser = await puppeteer.launch({
});
let checks = 0;
const check = (name, actual, expected) => {
report.checks.push({ name, actual, expected });
assert.deepEqual(actual, expected, name);
checks++;
console.log(`[PASS] ${name}`);
@@ -86,12 +75,6 @@ async function open(mode = 'normal') {
const page = await browser.newPage();
await page.bringToFront();
const errors = [];
if (extended)
watchW1Page(page, report, {
name: mode,
baseUrl: appUrl,
preview: process.argv.includes('--preview'),
});
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(
@@ -151,17 +134,15 @@ async function open(mode = 'normal') {
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, engine } = window.__godsEyeView;
await window.__godsEyeView.styleManager.initialRestorePromise;
window.__godsEyeView.styleManager.orbitController.stop();
const { dataManager, viewer } = window.__godsEyeView;
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
const Cartesian3 = viewer.camera.position.constructor;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
@@ -169,211 +150,6 @@ async function open(mode = 'normal') {
});
return { page, errors };
}
async function nextFrame(page) {
await page.evaluate(
() =>
new Promise((resolve) => {
const engine = window.__godsEyeView.engine;
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
engine.render.request('gbfs-raster-qa');
}),
);
}
async function settledTiles(page) {
await page.evaluate(async () => {
const engine = window.__godsEyeView.engine;
const start = performance.now();
let since = null;
while (performance.now() - start < 90000) {
if (engine.surface.tilesLoaded()) since ??= performance.now();
else since = null;
if (since !== null && performance.now() - since > 1500) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('Map tiles did not settle for raster sampling');
});
}
async function w2Checks(page) {
await page.evaluate(async () => {
const app = window.__godsEyeView;
const detection = await import('/src/data/detection.js');
detection.setMode('OFF');
const { appClock } = await import('/src/time/clock.js');
appClock.freeze(Date.parse('2026-10-28T19:00:00Z'));
const canvas = app.engine.host.canvas;
const style = document.createElement('style');
style.textContent =
'.gbfs-raster-hidden { visibility: hidden !important; }';
document.head.append(style);
for (const element of document.body.querySelectorAll('*'))
if (element !== canvas && !element.contains(canvas))
element.classList.add('gbfs-raster-hidden');
});
for (const [name, height] of [
['city', 10000],
['close', 3000],
['global', 100000],
]) {
await page.evaluate(async (height) => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('bikeshare', false);
const geo = await import('/src/geo/index.js');
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, height);
await settledTiles(page);
await nextFrame(page);
const off = await page.screenshot({
path: path.join(shots, `${name}-off.png`),
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
);
await page.waitForFunction(
(count) =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count === count,
{},
name === 'global' ? 0 : 5,
);
await nextFrame(page);
const controls = await page.evaluate(() => {
const app = window.__godsEyeView;
const rect = app.engine.host.canvas.getBoundingClientRect();
return app.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.map((record) => {
const p = app.engine.projector.worldToWindow(record.position);
return { id: record.sourceId, x: p.x + rect.left, y: p.y + rect.top };
});
});
const on = await page.screenshot({
path: path.join(shots, `${name}-on.png`),
});
const a = await sharp(off)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const b = await sharp(on).ensureAlpha().raw().toBuffer();
for (const control of controls) {
let pixels = 0;
for (
let y = Math.max(0, Math.floor(control.y) - 16);
y < Math.min(a.info.height, control.y + 16);
y++
)
for (
let x = Math.max(0, Math.floor(control.x) - 16);
x < Math.min(a.info.width, control.x + 16);
x++
) {
const i = (y * a.info.width + x) * 4;
if (
Math.max(
...[0, 1, 2].map((c) => Math.abs(a.data[i + c] - b[i + c])),
) > 3
)
pixels++;
}
control.changedPixels = pixels;
}
report.poses[name] = controls;
check(
`${name} expected station population`,
controls.length,
name === 'global' ? 0 : 5,
);
if (controls.length)
check(
`${name} five nonempty marker raster controls`,
controls.every((c) => c.changedPixels > 0),
true,
);
}
await page.evaluate(() => {
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
element.classList.remove('gbfs-raster-hidden');
});
await page.evaluate(async () => {
const app = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await app.dataManager.setEnabled('bikeshare', false);
});
for (let i = 0; i < 10; i++) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count === 5,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', false),
);
}
check(
'ten cycles clear all records',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count,
),
0,
);
const idle = await page.evaluate(async () => {
const app = window.__godsEyeView;
let frames = 0,
last = performance.now();
const remove = app.engine.frames.postRender.addEventListener(() => {
frames++;
last = performance.now();
});
const start = performance.now();
while (
performance.now() - start < 60000 &&
(performance.now() - last < 2000 || !app.engine.surface.tilesLoaded())
)
await new Promise((resolve) => setTimeout(resolve, 100));
const ready =
performance.now() - last >= 2000 && app.engine.surface.tilesLoaded();
frames = 0;
await new Promise((resolve) => setTimeout(resolve, 2000));
remove();
return { ready, frames, governor: app.getRenderGovernorDiagnostics() };
});
report.idle = idle;
check('camera and tiles settle before idle sample', idle.ready, true);
check(
'disabled static scene renders zero frames over two seconds',
idle.frames,
0,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
.count === 5,
);
}
try {
const { page, errors } = await open();
await page.waitForFunction(
@@ -382,17 +158,6 @@ try {
.count === 5,
{ timeout: 15000 },
);
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const rendered = new Promise((resolve) => {
const remove = engine.frames.postRender.addEventListener(() => {
remove();
resolve();
});
});
window.__godsEyeView.requestRender('gbfs-name-pick-qa');
await rendered;
});
const records = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
@@ -433,39 +198,17 @@ try {
),
true,
);
if (extended) await w2Checks(page);
const clickPoint = await page.evaluate(() => {
window.__qaPainted = [];
const { engine } = window.__godsEyeView;
const station = window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.find(({ sourceId }) => sourceId === 'austin-capmetro:english');
const point = engine.projector.worldToWindow(station.position);
const rect = engine.host.canvas.getBoundingClientRect();
const picked = engine.picker.pick(point);
return {
x: rect.left + point.x,
y: rect.top + point.y,
pickedId: typeof picked?.id === 'string' ? picked.id : null,
primitiveId:
typeof picked?.primitive?.id === 'string' ? picked.primitive.id : null,
};
const { viewer } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor;
const point = viewer.scene.cartesianToCanvasCoordinates(
Cartesian3.fromDegrees(-97.7469, 30.2681),
);
const rect = viewer.scene.canvas.getBoundingClientRect();
return { x: rect.left + point.x, y: rect.top + point.y };
});
check(
'the scene pick resolves the first stable station key',
clickPoint.pickedId || clickPoint.primitiveId,
'austin-capmetro:english',
);
await page.mouse.click(clickPoint.x, clickPoint.y);
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.some((record) => record.skipLabel),
{ timeout: 10000 },
);
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
@@ -520,15 +263,15 @@ try {
);
}
await page.setViewport({ width: 390, height: 844 });
await page.evaluate(async () => {
await page.evaluate(() => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
const geo = await import('/src/geo/index.js');
app.engine.camera.cancelFlight();
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
const Cartesian3 = app.viewer.camera.position.constructor;
app.viewer.camera.cancelFlight();
app.viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
@@ -587,20 +330,7 @@ try {
check(`${mode} produces no page exceptions`, errors, []);
await page.close();
}
if (extended)
check(
'all pages have no unexpected runtime errors',
report.browserErrors,
[],
);
report.pass = true;
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
} catch (error) {
report.pass = false;
report.failure = redact(error.message);
throw error;
} finally {
if (reportPath)
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
await browser.close();
}
-190
View File
@@ -1,190 +0,0 @@
import { fromDegrees } from '../src/geo/cartesian3.js';
import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const strict = createStrict({ origin: base.origin, layers: ['s2-ghostmaps'] });
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
const file = await readFile(
new URL('./fixtures/ghostmaps-scenes.kmz', import.meta.url),
);
await p.evaluateOnNewDocument(
(bytes) => {
const fetch = window.fetch.bind(window);
const events = window.EventSource;
window.ghostStats = { streams: 0, reads: 0, catalogs: 0, titles: [] };
window.EventSource = class extends EventTarget {
constructor(url, options) {
super();
if (!url.includes('/api/ghostmaps/events'))
return new events(url, options);
window.ghostStats.streams++;
this.readyState = 1;
}
close() {}
};
window.fetch = (input, options) => {
const { pathname } = new URL(input.url ?? input, document.baseURI);
if (pathname === '/api/ghostmaps/catalog') {
window.ghostStats.catalogs++;
return Promise.resolve(
Response.json({
commit: 'qa',
products: [
{ id: 'cip', version: 'qa', title: 'QA', defaultOn: true },
],
}),
);
}
if (pathname.startsWith('/api/ghostmaps/file/')) {
window.ghostStats.reads++;
return Promise.resolve(new Response(new Uint8Array(bytes)));
}
return fetch(input, options);
};
const fill = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (value, ...args) {
if (/QA (pin|route|zone)/i.test(value))
window.ghostStats.titles.push(value);
return fill.call(this, value, ...args);
};
},
[...file],
);
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { dataManager } = window.__godsEyeView;
await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' });
window.qaGhost = dataManager.layers.get('s2-ghostmaps').module;
});
const click = async (lon, lat) => {
const { x, y } = await p.evaluate(
(pos) => {
const { engine } = window.__godsEyeView;
const point = engine.projector.worldToWindow(pos, {});
const box = engine.host.canvas.getBoundingClientRect();
window.ghostStats.titles = [];
return { x: box.left + point.x, y: box.top + point.y };
},
fromDegrees(lon, lat),
);
await p.mouse.click(x, y);
};
const overlay = () =>
p.evaluate(() => {
const canvas = document.getElementById('world-overlay-canvas');
const data = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let pixels = 0;
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
return pixels;
});
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await p.evaluate(() => {
window.ghostStats.titles = [];
});
await strict.switchTo(p, kind);
await p.waitForFunction(() =>
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
);
}
const inventory = await p.evaluate(() => ({
same:
window.qaGhost ===
window.__godsEyeView.dataManager.layers.get('s2-ghostmaps').module,
names: window.qaGhost.getAnalystRecords().map((r) => r.name),
streams: window.ghostStats.streams,
reads: window.ghostStats.reads,
}));
assert.deepEqual(inventory, {
same: true,
names: ['QA pin', 'QA route', 'QA zone'],
streams: 1,
reads: 1,
});
await strict.passed(
p,
's2-ghostmaps',
'same model, three features, one event stream, one KMZ read',
);
await new Promise((resolve) => setTimeout(resolve, 2200));
for (const [name, lon, lat] of [
['QA pin', -79.38, 43.65],
['QA route', -79.375, 43.71],
['QA zone', -79.28, 43.61],
]) {
await click(lon, lat);
await p.waitForFunction(
(name) =>
window.ghostStats.titles.some((v) =>
v.toLowerCase().includes(name.toLowerCase()),
),
{ timeout: 12000 },
name,
);
await strict.passed(
p,
's2-ghostmaps',
`a click on ${name} draws its card text`,
);
console.log('PASS', kind, name, 'pointer hit');
}
// Not vacuous: the zone's card is up, so the overlay only clears when
// the click in the hole is handled as a click on empty ground. The
// overlay without any card is measured with a click far from every
// feature.
const shown = await overlay();
await click(-79.316, 43.61);
await p.waitForFunction(
(shown) => {
const canvas = document.getElementById('world-overlay-canvas');
const data = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let pixels = 0;
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
return pixels < shown - 100;
},
{ timeout: 10000 },
shown,
);
const hole = await overlay();
await click(-79.45, 43.55);
await new Promise((resolve) => setTimeout(resolve, 800));
const empty = await overlay();
console.log(
kind,
'overlay pixels: zone card',
shown,
'hole',
hole,
'empty ground',
empty,
);
await strict.check(
p,
's2-ghostmaps',
'a click in the zone hole clears the selected zone card like empty ground',
shown > empty + 100 && Math.abs(hole - empty) <= 0.02 * shown,
{ shown, hole, empty },
);
await click(-79.28, 43.61);
await p.waitForFunction(() =>
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
);
}
assert.deepEqual(errors, []);
});
+73
View File
@@ -28,6 +28,8 @@
* (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.
* (viii) AGE — seven days is the default; shorter/longer windows and All
* markers update the globe, and the age choice survives a reload.
*
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
*
@@ -224,6 +226,77 @@ async function main() {
record('LIVE: the /api/ghostmaps/events stream is open', stream);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
const ageChips = await page.evaluate(
(id) =>
[
...document.querySelectorAll(
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
),
].map((option) => ({
id: option.dataset.chipId,
active: option.getAttribute('aria-checked') === 'true',
})),
LAYER_ID,
);
record(
'AGE: seven days is the default with an All markers option',
live.ageDays === 7 &&
ageChips.length === 6 &&
ageChips
.filter((chip) => chip.active)
.map((chip) => chip.id)
.join() === 'age-7' &&
ageChips.some((chip) => chip.id === 'age-all'),
);
const selectAge = async (days) => {
await showRow(page);
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
await page.waitForSelector(selector, { timeout: 15000 });
// Dispatch through the rendered button, matching the product-chip gate.
await page.$eval(selector, (button) => button.click());
await page.waitForFunction(
(id, age) => {
const s = window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats();
return s.ageDays === age && !s.loading;
},
{ timeout: TIMEOUT_MS },
LAYER_ID,
days,
);
return stats(page);
};
const all = await selectAge(null);
record(
'AGE: All markers restores old events without duplicate data sources',
all.count > live.count &&
(await sources(page)).length === live.products.length,
`${live.count} → ${all.count}`,
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: TIMEOUT_MS,
});
await enableAndSettle(page);
const persistedAge = await stats(page);
record(
'AGE: All markers survives a reload',
persistedAge.ageDays === null && persistedAge.count === all.count,
);
const thirty = await selectAge(30);
const one = await selectAge(1);
const seven = await selectAge(7);
record(
'AGE: counts follow the window order and seven days restores the default',
one.count <= live.count &&
thirty.count >= live.count &&
thirty.count <= all.count &&
seven.count === live.count &&
seven.ageDays === 7,
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
);
// ---- (ii) PICK ----------------------------------------------------------
const target = await page.evaluate((id) => {
const gev = window.__godsEyeView;
-505
View File
@@ -1,505 +0,0 @@
import { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp';
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
const strict = createStrict({
origin: base.origin,
layers: [
'ais-live-vessels',
'air-quality',
'gnss-integrity',
'fire-perimeters',
],
});
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
await p.evaluateOnNewDocument(() => {
const fetch = window.fetch.bind(window);
window.fetch = (input, o) => {
const url = new URL(input.url ?? input, document.baseURI);
if (url.pathname === '/api/vessels/track') {
window.qaVesselHistories = (window.qaVesselHistories ?? 0) + 1;
return Promise.resolve(
Response.json({ samples: [{ lat: 43.63, lon: -79.38 }] }),
);
}
if (url.pathname === '/api/vessels') {
return Promise.resolve(
Response.json({
status: 'live',
rows: [
{
mmsi: '123456789',
name: 'Toronto QA',
lat: 43.65,
lon: -79.38,
last_position_epoch: Date.now() / 1000,
},
],
}),
);
}
if (url.pathname.startsWith('/api/fire-perimeters/inciweb')) {
return Promise.resolve(Response.json({ rows: [] }));
}
if (url.pathname === '/api/fire-perimeters') {
const square = (dx) => [
[-79.38 - dx, 43.65 - dx],
[-79.38 + dx, 43.65 - dx],
[-79.38 + dx, 43.65 + dx],
[-79.38 - dx, 43.65 + dx],
[-79.38 - dx, 43.65 - dx],
];
return Promise.resolve(
Response.json({
rows: [
{
stableId: 'fire:fixture',
name: 'QA Fire',
acres: 500,
containedPct: 40,
polygons: [[square(0.09), square(0.018).reverse()]],
},
],
}),
);
}
if (url.pathname === '/api/gnss-integrity') {
return Promise.resolve(
Response.json({
ageMs: 0,
rows: ['a1', 'a2', 'a3'].map((hex) => ({
hex,
lat: 43.65,
lon: -79.38,
nic: 0,
nacp: 0,
degraded: true,
gpsLost: false,
})),
}),
);
}
if (url.hostname === 'api.weather.gc.ca') {
const records = url.pathname.includes('aqhi-stations')
? [
{
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
properties: {
location_id: 'QA1',
location_name_en: 'QA Toronto',
},
},
]
: [
{
properties: {
location_id: 'QA1',
aqhi: 9,
observation_datetime: new Date().toISOString(),
},
},
];
return Promise.resolve(
Response.json({
type: 'FeatureCollection',
features: records.map((f) => ({ type: 'Feature', ...f })),
}),
);
}
return fetch(input, o);
};
});
await p.goto(base.href);
const capture = async (visible, layerId = 'air-quality') => {
await p.evaluate(
async (show, id) => {
const d = window.__godsEyeView;
await d.dataManager.setEnabled(id, show, { origin: 'user' });
d.engine.render.request('qa-air-quality');
},
visible,
layerId,
);
// Ready to compare: the layer's data is in (when shown), the globe's
// tiles under the view have loaded and stayed loaded, and a frame has
// been drawn since.
await p
.waitForFunction(
(show, id) => {
const d = window.__godsEyeView;
if (!d?.engine) return false;
const module = d.dataManager.layers.get(id).module;
const data =
!show ||
(id === 'air-quality'
? module.getAnalystRecords().length === 1
: module.getStats().levels?.high === 1);
const now = performance.now();
// The engines draw on request: keep frames coming so tiles load.
d.engine.render.request('qa-ground-settle');
if (!data || !d.engine.surface.tilesLoaded()) {
window.__qaSettledSince = null;
return false;
}
window.__qaSettledSince ??= now;
return now - window.__qaSettledSince > 1200;
},
{ polling: 100, timeout: 30000 },
visible,
layerId,
)
.catch(async (error) => {
console.log(
'not settled',
JSON.stringify(
await p.evaluate((id) => {
const d = window.__godsEyeView;
const module = d?.dataManager.layers.get(id).module;
return {
engine: d?.engine?.kind,
tiles: d?.engine?.surface.tilesLoaded(),
records: module?.getAnalystRecords().length,
stats: module?.getStats(),
};
}, layerId),
),
);
throw error;
});
// A frame drawn after the settle (the engine draws on request), then
// the animation frame that presents it.
await p.evaluate(
() =>
new Promise((resolve) => {
const { engine } = window.__godsEyeView;
const remove = engine.frames.postRender.addEventListener(() => {
remove();
requestAnimationFrame(() => resolve());
});
engine.render.request('qa-ground');
}),
);
await p.evaluate(() => (window.__qaSettledSince = null));
return sample();
};
const sample = async () => {
const shot = await p.screenshot();
return sharp(shot)
.extract({ left: 537, top: 403, width: 22, height: 18 })
.removeAlpha()
.png()
.toBuffer()
.then((buf) => sharp(buf).stats());
};
// The layer shown, sampled until its polygon appears in the patch (the
// engines draw a clamped polygon a few frames after its data, with no
// signal for it), for at most 20 s; the comparison stays the same.
const orangeOf = (v) => v.channels[0].mean - v.channels[2].mean;
const drawn = async (off, layerId) => {
const deadline = Date.now() + 20000;
let on = await capture(true, layerId);
while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 500));
on = await sample();
}
return on;
};
await waitForEngine(p, 'navara');
await p.evaluate(() => {
const d = window.__godsEyeView;
if (d.tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless required');
window.qaAir = d.dataManager.layers.get('air-quality').module;
window.qaGnss = d.dataManager.layers.get('gnss-integrity').module;
});
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await strict.switchTo(p, kind);
const same = await p.evaluate(
() =>
window.__godsEyeView.dataManager.layers.get('air-quality').module ===
window.qaAir &&
window.__godsEyeView.dataManager.layers.get('gnss-integrity')
.module === window.qaGnss,
);
await strict.check(
p,
['air-quality', 'gnss-integrity'],
'same layer models after the switch',
same,
);
}
await p.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('gnss-integrity', false),
);
const off = await capture(false),
on = await drawn(off, 'air-quality');
const values = await p.evaluate(() => ({
records: window.qaAir.getAnalystRecords(),
card: window.qaAir.getRowControls().list.items,
stats: window.qaAir.getStats(),
})); // An interior patch away from the label must take on the high-risk orange.
const orange = (v) => v.channels[0].mean - v.channels[2].mean;
assert.ok(
orange(on) > orange(off) + 5,
kind + ' must draw the station footprint',
);
console.log(
'PASS',
kind,
values.records[0].id,
'AQHI',
values.records[0].value,
);
assert.equal(values.records[0].name, 'QA Toronto');
assert.equal(values.records[0].value, 9);
assert.equal(values.card.length, 1);
await strict.passed(
p,
'air-quality',
'AQHI 9 station footprint drawn, one card row',
{
off: orange(off),
on: orange(on),
},
);
await capture(false);
const cellOff = await capture(false, 'gnss-integrity');
const cellOn = await drawn(cellOff, 'gnss-integrity');
assert.ok(
orange(cellOn) > orange(cellOff) + 5,
kind + ' must draw GNSS high cell',
);
const gnss = await p.evaluate(() => window.qaGnss.getStats());
assert.equal(gnss.levels.high, 1);
await strict.passed(p, 'gnss-integrity', 'one high GNSS cell drawn', {
off: orange(cellOff),
on: orange(cellOn),
});
console.log(
'PASS',
kind,
'GNSS high cell, view anchor',
gnss.coverage.latitude,
gnss.coverage.longitude,
);
await p.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('air-quality', true),
);
}
await p.evaluate(async () => {
const { dataManager } = window.__godsEyeView;
await dataManager.setEnabled('air-quality', false);
await dataManager.setEnabled('fire-perimeters', true, { origin: 'user' });
window.qaFire = dataManager.layers.get('fire-perimeters').module;
if (
!dataManager.isEnabled('fire-perimeters') ||
window.qaFire.getStats().count !== 1
)
throw Error(
'Failed to enable fire perimeter fixture: ' +
JSON.stringify(window.qaFire.getStats()),
);
});
const card = async (visible) =>
p.waitForFunction(
(want) => {
const canvas = document.getElementById('world-overlay-canvas');
const data = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let pixels = 0;
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
return want ? pixels > 100 : pixels === 0;
},
{ timeout: 15000 },
visible,
);
const click = async (lon, lat) => {
const { x, y } = await p.evaluate(
(world) => {
const { engine } = window.__godsEyeView;
const point = engine.projector.worldToWindow(world, {});
if (!point) throw Error('the point is not in view');
const box = engine.host.canvas.getBoundingClientRect();
return { x: box.left + point.x, y: box.top + point.y };
},
fromDegrees(lon, lat),
);
await p.mouse.click(x, y);
};
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await strict.switchTo(p, kind);
await card(true);
await strict.check(
p,
'fire-perimeters',
'same model; the incident card survives the switch',
await p.evaluate(
() =>
window.qaFire ===
window.__godsEyeView.dataManager.layers.get('fire-perimeters')
.module,
),
);
}
await click(-79.51, 43.65);
await card(false);
await strict.passed(
p,
'fire-perimeters',
'a click outside the perimeter clears the card',
);
await click(-79.435, 43.65);
await card(true);
await strict.check(
p,
'fire-perimeters',
'a click on the ring selects the fire',
await p.evaluate(() => window.qaFire.getSelectedInfo?.()?.id ?? true),
);
// Not vacuous: the fire is selected, so the card only goes when the
// click in the hole is handled as a click outside the polygon.
await click(-79.38, 43.65);
await card(false);
await strict.passed(
p,
'fire-perimeters',
'a click in the hole deselects the selected fire',
);
await click(-79.435, 43.65);
await card(true);
console.log(
'PASS',
kind,
'select fire under GNSS, exclude hole, retain incident card',
);
}
await p.evaluate(async () => {
const { dataManager } = window.__godsEyeView;
for (const id of ['gnss-integrity', 'fire-perimeters', 'air-quality'])
await dataManager.setEnabled(id, false);
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
window.qaVesselEntry = dataManager.layers.get('ais-live-vessels');
});
await p.waitForFunction(
() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show,
);
await new Promise((resolve) => setTimeout(resolve, 800));
// Click-picking on the running engine: clear the selection with a click on
// empty water, then select the vessel with a click on its chevron.
const pickVessel = async () => {
// Empty ground: a point in the middle of the view where the engine
// picks nothing and nothing covers the canvas.
const empty = await p
.waitForFunction(
() => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (let y = 220; y < 560; y += 11)
for (let x = 300; x < 720; x += 11) {
if (
!canvas.contains(
document.elementFromPoint(box.left + x, box.top + y),
)
)
continue;
let free = true;
for (const [dx, dy] of [
[0, 0],
[10, 0],
[-10, 0],
[0, 10],
[0, -10],
])
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
if (free) return { x: box.left + x, y: box.top + y };
}
return false;
},
{ timeout: 15000 },
)
.then((handle) => handle.jsonValue());
await p.mouse.click(empty.x, empty.y);
await p.waitForFunction(
() => !window.qaVesselEntry.module.getSelectedInfo(),
);
await new Promise((resolve) => setTimeout(resolve, 600));
await click(-79.38, 43.65);
await p.waitForFunction(
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
{ timeout: 15000 },
);
await strict.passed(
p,
'ais-live-vessels',
'a click on the chevron selects the vessel',
);
};
await click(-79.38, 43.65);
await p.waitForFunction(
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
);
await strict.passed(
p,
'ais-live-vessels',
'a click on the chevron selects the vessel',
);
let histories = await p.evaluate(() => window.qaVesselHistories);
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await strict.switchTo(p, kind);
}
const sample = await p.evaluate(() => {
const entry =
window.__godsEyeView.dataManager.layers.get('ais-live-vessels');
const drawn = entry.module.qaDrawn();
return {
retained: entry === window.qaVesselEntry,
selected: entry.module.getSelectedInfo()?.mmsi,
chevron: drawn.chevrons.find((c) => c.selected && c.show)?.id,
card: drawn.cards.find((c) => c.selected)?.id,
histories: window.qaVesselHistories,
};
});
assert.deepEqual(
sample,
{
retained: true,
selected: '123456789',
chevron: '123456789',
card: 'vessel:123456789',
histories,
},
kind,
);
await strict.passed(
p,
'ais-live-vessels',
'same entry; selection, chevron and card kept; no new track request',
);
if (kind === 'cesium' || histories === 1) {
await pickVessel();
histories = await p.evaluate(() => window.qaVesselHistories);
}
console.log(
'PASS',
kind,
'retained selected AIS vessel, chevron, card and single track request',
);
}
assert.deepEqual(errors, []);
});
+24 -57
View File
@@ -99,7 +99,6 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
// ---------------------------------------------------------------------------
// Args (same shape as track-regression.mjs)
@@ -111,13 +110,7 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
if (requestedEngine) {
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
appUrl.searchParams.set('engine', requestedEngine);
}
const APP_URL = appUrl.href;
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/b3');
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
@@ -155,7 +148,7 @@ function record(name, ok, detail) {
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
const viewer = (window.__headingView ?? window.__godsEyeView?.viewer);
const viewer = window.__godsEyeView?.viewer;
const scene = viewer?.scene;
const history = [];
const startedAt = performance.now();
@@ -460,7 +453,7 @@ async function main() {
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]),
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
@@ -487,7 +480,6 @@ async function main() {
// ---- Turning-plane fetch shim (installed before any app code runs) ------
await page.evaluateOnNewDocument((turn) => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__TURN = turn;
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
window.__TURN_HITS = { opensky: 0, mil: 0 };
@@ -582,12 +574,9 @@ async function main() {
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
{ timeout: 60000, polling: 200 }
);
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
console.log(' App globals ready.');
// ---- Model finder + course derivation, installed once ------------------
@@ -600,7 +589,7 @@ async function main() {
// ~90° course spike into the sampled series — a measurement flake, not
// a product regression.)
window.__findTrackedModel = function (icao) {
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
const out = [];
const walk = (coll) => {
const n = coll.length;
@@ -636,7 +625,7 @@ async function main() {
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
window.__courseFromModelMatrix = function (mm, headingOffsetDeg) {
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
const C3 = v.camera.position.constructor;
const Carto = v.camera.positionCartographic.constructor;
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
@@ -657,7 +646,7 @@ async function main() {
// preUpdate/preRender listeners and reads the rendered model matrix, but
// does not depend on ambient render-loop scheduling for sample count.
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
const tileset = window.__godsEyeView.tileset;
const out = [];
const start = performance.now();
@@ -696,15 +685,13 @@ async function main() {
};
});
await installNavaraHeadingProbe(page);
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------
// First update() fires inside setEnabled, so the FIRST fix must already be
// the oldest (history appends only monotonically-newer fix times).
console.log('Priming turning history through the render delay (30 s / 15 s)...');
const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
window.__TURN.timeOffsetSec = -32;
await dm.setEnabled('flights', true);
await dm.setEnabled('military', true);
@@ -739,10 +726,7 @@ async function main() {
const flModelUp = await page.waitForFunction((icao) => {
const m = window.__findTrackedModel(icao);
const app = window.__godsEyeView;
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
return !!(m && m.ready && m.show && selected?.icao24 === icao && following);
return !!(m && m.ready && m.show);
}, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false);
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
if (!flModelUp) { finish(); return; }
@@ -799,14 +783,14 @@ async function main() {
await sleep(800);
const shots = [
['flights-far-orbitA.png', async () => { /* default follow framing */ }],
['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })],
['flights-far-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.9); })],
['flights-near-orbitA.png', () => page.evaluate(() => {
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
const cam = window.__godsEyeView.viewer.camera;
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
// readout anchor, tracking line, and trail can be judged close-up.
cam.rotateRight(-1.9); cam.zoomIn(5200);
})],
['flights-near-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateLeft(1.2); })],
['flights-near-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateLeft(1.2); })],
];
for (const [name, move] of shots) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
@@ -828,10 +812,7 @@ async function main() {
const milModelUp = await page.waitForFunction((hex) => {
const m = window.__findTrackedModel(hex);
const app = window.__godsEyeView;
const selected = app.dataManager.layers.get('military').module.getTrackedInfo();
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
return !!(m && m.ready && m.show && selected?.icao24 === hex && following);
return !!(m && m.ready && m.show);
}, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false);
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
if (milModelUp) {
@@ -860,14 +841,14 @@ async function main() {
await sleep(800);
for (const [name, move] of [
['military-orbitA.png', async () => { }],
['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })],
['military-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.6); })],
['military-near-orbitA.png', () => page.evaluate(() => {
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
const cam = window.__godsEyeView.viewer.camera;
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
cam.rotateLeft(1.6); cam.zoomIn(5700);
})],
['military-near-orbitB.png', () => page.evaluate(() => {
(window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.2);
window.__godsEyeView.viewer.camera.rotateRight(1.2);
})],
]) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
@@ -896,10 +877,7 @@ async function main() {
}, icao);
const up = await page.waitForFunction((id) => {
const m = window.__findTrackedModel(id);
const app = window.__godsEyeView;
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
return !!(m && m.ready && m.show && selected?.icao24 === id && following);
return !!(m && m.ready && m.show);
}, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false);
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
return up;
@@ -936,7 +914,7 @@ async function main() {
});
for (const [name, move] of [
['slowturn-far.png', async () => { /* default follow framing */ }],
['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })],
['slowturn-near.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.zoomIn(2500); })],
]) {
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
await sleep(1200);
@@ -952,9 +930,8 @@ async function main() {
// ========================================================================
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
await page.evaluate(() => {
if (window.__headingView) { window.__findModelByIcao = window.__findTrackedModel; return; }
window.__findModelByIcao = function (icao) {
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
const out = [];
const walk = (coll) => {
const n = coll.length;
@@ -980,7 +957,7 @@ async function main() {
window.__TURN.heli65.active = true;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
fl.setParams({ models3d: false });
await fl.update((window.__headingView ?? window.__godsEyeView.viewer)); // ingest the first heli fix
await fl.update(window.__godsEyeView.viewer); // ingest the first heli fix
});
await sleep(4000); // several fleet ticks establish the heli's fleet course
await page.evaluate((icao) => {
@@ -989,7 +966,7 @@ async function main() {
const heliTracked = await page.waitForFunction((icao) => {
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const ti = fl.getTrackedInfo();
return !!(ti && ti.icao24 === icao && (window.__headingView ?? window.__godsEyeView.viewer).trackedEntity);
return !!(ti && ti.icao24 === icao && window.__godsEyeView.viewer.trackedEntity);
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
heliTracked ? 'tracked' : 'tracking never engaged');
@@ -1006,10 +983,9 @@ async function main() {
// rotation after. The release-in-place keeps the camera static, so
// rotation deltas across the boundary are course deltas.
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const findFleetBillboard = (id) => {
if (window.__headingView) return window.__godsEyeView.layers.drawn('flights').find((row) => row.id === id && row.show);
const out = [];
const walk = (coll) => {
const n = coll.length;
@@ -1061,16 +1037,7 @@ async function main() {
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
});
const fleetModelUp = await page.evaluate(async (icao) => {
if (window.__headingView) {
const deadline = performance.now() + 40000;
while (performance.now() < deadline) {
if (window.__findModelByIcao(icao)) return true;
window.__godsEyeView.engine.render.request('heading-probe');
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
const tileset = window.__godsEyeView.tileset;
const priorDefaultLoop = v.useDefaultRenderLoop;
const priorTilesetShow = tileset?.show;
@@ -1094,7 +1061,7 @@ async function main() {
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
} else {
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
const v = (window.__headingView ?? window.__godsEyeView.viewer);
const v = window.__godsEyeView.viewer;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const out = [];
const start = performance.now();
-128
View File
@@ -1,128 +0,0 @@
// Navara ports for the existing heading scenarios. No app viewer is installed.
// Samples come from the renderer handles through the read-only layer probe.
export async function installNavaraHeadingProbe(page) {
await page.evaluate(async () => {
const app = window.__godsEyeView;
if (app.engine.kind !== 'navara') return;
const geo = await import('/src/geo/index.js');
const engine = app.engine;
const drawn = (id) =>
['flights', 'military']
.flatMap((layer) => app.layers.drawn(layer) ?? [])
.filter((row) => row.id === id);
window.__findTrackedModel = (id) => {
const rows = drawn(id);
const row =
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
rows.find((r) => r.model?.ready && r.model.show);
return row
? {
id,
ready: row.model.ready,
show: row.model.show,
modelMatrix: row.model.matrix,
}
: null;
};
window.__courseFromModelMatrix = (mm, offset) => {
if (!mm) return null;
const c = geo.cartographic.fromCartesian({
x: mm[12],
y: mm[13],
z: mm[14],
});
const ex = -Math.sin(c.longitude),
ey = Math.cos(c.longitude);
const nx = -Math.sin(c.latitude) * Math.cos(c.longitude);
const ny = -Math.sin(c.latitude) * Math.sin(c.longitude),
nz = Math.cos(c.latitude);
const h =
(Math.atan2(
-(mm[0] * nx + mm[1] * ny + mm[2] * nz),
mm[0] * ex + mm[1] * ey,
) *
180) /
Math.PI;
return (((h - offset) % 360) + 360) % 360;
};
const turn = (angle = 0, nearer = 0) => {
const handle = engine.camera.following();
const target = handle?.target();
if (!target)
throw new Error('Heading screenshot needs a followed target');
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
const inverse = geo.matrix4.inverseTransformation(
frame,
geo.matrix4.create(),
);
const p = geo.matrix4.multiplyByPoint(
inverse,
engine.camera.pose().position,
geo.cartesian3.create(),
);
const x = p.x * Math.cos(angle) - p.y * Math.sin(angle);
const y = p.x * Math.sin(angle) + p.y * Math.cos(angle);
const range = Math.hypot(x, y, p.z);
const scale = Math.max(150, range - nearer) / range;
handle.retarget({
viewFrom: { x: x * scale, y: y * scale, z: p.z * scale },
});
};
window.__headingView = {
clock: { currentTime: undefined },
get trackedEntity() {
const handle = engine.camera.following();
if (!handle) return null;
const label = handle.label;
const id = label?.id?.split(':').at(-1);
return {
gevTrackedId: label?.id,
gevLabelModel:
typeof label?.model === 'function' ? label.model() : label?.model,
gevDisplayPosition: handle.target,
billboard: {
rotation: {
getValue: () => drawn(id).find((r) => r.tracked)?.trackedRotation,
},
},
};
},
camera: {
rotateRight: (a) => turn(a),
rotateLeft: (a) => turn(-a),
zoomIn: (m) => turn(0, m),
},
scene: {
preRender: engine.frames.preRender,
postRender: engine.frames.postRender,
requestRender: () => engine.render.request('heading-probe'),
},
};
// Observe real Navara frames; unlike Cesium this port does not own a
// manual viewer loop. The 600 ms case samples at that cadence while
// Navara continues rendering; the dt-clamp branch is covered by unit QA.
window.__sampleModelCourse = (id, offset, windowMs, stepMs = 100) =>
new Promise((resolve) => {
const samples = [];
let last = -Infinity;
const remove = engine.frames.preRender.addEventListener(() => {
const now = performance.now();
if (now - last < stepMs) return;
last = now;
const model = window.__findTrackedModel(id);
samples.push({
tMs: now,
epochMs: Date.now(),
course: model
? window.__courseFromModelMatrix(model.modelMatrix, offset)
: null,
});
});
setTimeout(() => {
remove();
resolve(samples);
}, windowMs);
engine.render.request('heading-probe');
});
});
}
+7 -17
View File
@@ -213,8 +213,7 @@ async function readCameraGround(page, camId) {
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
*/
async function fetchReearthEllipsoid(lat, lon) {
const url = new URL('/api/terrain/heights', APP_URL);
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) return null;
@@ -254,7 +253,7 @@ async function main() {
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
@@ -280,7 +279,7 @@ async function main() {
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
{ timeout: 60000 }
);
await sleep(4000);
@@ -288,10 +287,6 @@ async function main() {
// =========================================================================
// Group 1: CCTV ground correctness
// =========================================================================
const hasCctv = await page.evaluate(() => window.__godsEyeView.layers.available('cctv'));
let allCameras = [], londonCams = [], austinCams = [];
if (!hasCctv) console.log(' [SKIP] CCTV ground/regime groups are outside the Navara W1 catalog');
if (hasCctv) {
console.log('Enabling CCTV layer (full city-packs catalog)...');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
@@ -342,7 +337,7 @@ async function main() {
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
// Caltrans cameras are identified by proximity to the SF anchor instead
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
console.log(`Camera catalog: ${allCameras.length} total.`);
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
@@ -354,8 +349,8 @@ async function main() {
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
const sfCams = allCameras
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
.slice(0, 3);
@@ -461,8 +456,6 @@ async function main() {
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
}
}
// =========================================================================
// Group 2: Aircraft render altitude
// =========================================================================
@@ -518,7 +511,7 @@ async function main() {
// returns everything currently shown regardless of viewer position.
const nearbyList = await page.evaluate(() => {
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
const center = window.__godsEyeView.engine.camera.livePosition;
const center = window.__godsEyeView.viewer.camera.position;
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
});
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
@@ -633,7 +626,6 @@ async function main() {
// =========================================================================
// Group 3: Regime C re-resolve
// =========================================================================
if (hasCctv) {
console.log('Switching map stack to keyless OSM (regime C)...');
// Grab a London camera's ground BEFORE the switch (still whatever regime
// we were in — Google-3D, given the app's default).
@@ -702,8 +694,6 @@ async function main() {
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
}
}
// -----------------------------------------------------------------------
// Screenshot for human visual review (not a pass/fail gate).
// -----------------------------------------------------------------------
-243
View File
@@ -1,243 +0,0 @@
import { fromDegrees } from '../src/geo/cartesian3.js';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
// Every geometry of the bundled files (a Multi* feature counts each part),
// read from the files this checkout builds.
const geometries = (name) =>
readFileSync(
new URL(`../src/data/local_data/${name}/${name}.geojsonl`, import.meta.url),
'utf8',
)
.split('\n')
.filter((line) => line.trim())
.reduce((sum, line) => {
const { type, coordinates } = JSON.parse(line).geometry;
return sum + (type.startsWith('Multi') ? coordinates.length : 1);
}, 0);
const DATACENTERS = geometries('datacenters');
const DAMS = geometries('dams');
const strict = createStrict({
origin: base.origin,
layers: ['local-dams', 'local-datacenters'],
});
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
const reads = { datacenters: 0, dams: 0 };
p.on('request', (req) => {
const path = new URL(req.url()).pathname;
for (const name of Object.keys(reads))
if (path.includes(name) && path.endsWith('.geojsonl')) reads[name]++;
});
await p.evaluateOnNewDocument(() => {
window.qaSelections = [];
window.addEventListener('gev:entity-selected', ({ detail }) => {
if (detail?.layerId?.startsWith('local-'))
window.qaSelections.push(detail.id);
});
window.qaCleared = 0;
window.addEventListener('gev:entity-selection-cleared', () => {
window.qaCleared++;
});
});
await p.goto(base.href);
await waitForEngine(p, 'navara');
const ids = ['local-datacenters', 'local-dams'];
const dataset = await p.evaluate(async (ids) => {
const { dataManager, tileset } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
window.qaSites = [];
const records = [];
for (const id of ids) {
if (!(await dataManager.setEnabled(id, true, { origin: 'user' })))
throw Error(id + ' did not enable');
const mod = dataManager.layers.get(id).module;
window.qaSites.push(mod);
const all = mod.getAnalystRecords(Infinity);
records.push({
count: mod.getStats().count,
total: all.length,
name: all[0]?.name,
});
}
return records;
}, ids);
// The bundled files are fixed: every record is loaded.
assert.deepEqual(
dataset.map((d) => d.count),
[DATACENTERS, DAMS],
JSON.stringify(dataset),
);
const record = await p.evaluate(() =>
window.qaSites[0]
.getAnalystRecords(5000)
.find((r) => r.name === 'Geneseo Communications Inc'),
);
assert.ok(record);
const clickSite = async () => {
const screen = await p.evaluate(
(world) => {
const { engine } = window.__godsEyeView;
const at = engine.projector.worldToWindow(world, {});
const box = engine.host.canvas.getBoundingClientRect();
return { x: box.left + at.x, y: box.top + at.y };
},
fromDegrees(record.lon, record.lat),
);
await p.mouse.click(screen.x, screen.y);
await p.waitForFunction(
() => window.qaSelections.at(-1) === 'local-datacenters:342664739',
{ timeout: 15000 },
);
};
// A dam in view, clicked where its record is.
const dam = await p.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('local-dams')
.module.getAnalystRecords(5000)
.find((r) => r.name === 'Arsenal Power Dam'),
);
assert.ok(dam);
const clickDam = async () => {
const screen = await p.evaluate(
(world) => {
const { engine } = window.__godsEyeView;
const at = engine.projector.worldToWindow(world, {});
const box = engine.host.canvas.getBoundingClientRect();
window.qaSelections = [];
return { x: box.left + at.x, y: box.top + at.y };
},
fromDegrees(dam.lon, dam.lat),
);
await p.mouse.click(screen.x, screen.y);
await p
.waitForFunction(
() => window.qaSelections.at(-1) === 'local-dams:-12841971',
{ timeout: 15000 },
)
.catch(async (error) => {
console.log(
'dam click',
JSON.stringify(screen),
JSON.stringify(
await p.evaluate(({ x, y }) => {
const { engine } = window.__godsEyeView;
const box = engine.host.canvas.getBoundingClientRect();
const id = engine.picker.pick({
x: x - box.left,
y: y - box.top,
})?.id;
return {
selections: window.qaSelections,
cleared: window.qaCleared,
pick: String(id?.id ?? id),
hit: document.elementFromPoint(x, y)?.tagName,
};
}, screen),
),
);
throw error;
});
};
const start = fromDegrees(-90.507, 41.534, 20000);
const home = async () => {
await p.evaluate((destination) => {
const { camera, render } = window.__godsEyeView.engine;
camera.cancelFlight();
camera.setView({
destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
render.request('qa-home');
}, start);
await new Promise((resolve) => setTimeout(resolve, 2000));
};
const counts = { ...reads };
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await p.evaluate(() => {
window.qaSelections = [];
});
await strict.switchTo(p, kind);
await p.waitForFunction(
() => window.qaSelections.includes('local-datacenters:342664739'),
{ timeout: 15000 },
);
}
await new Promise((r) => setTimeout(r, 1500));
const result = await p.evaluate(
(ids) =>
ids.map((id, i) => {
const mod = window.__godsEyeView.dataManager.layers.get(id).module;
return {
same: mod === window.qaSites[i],
lod: mod.getLodDiagnostics(),
count: mod.getStats().count,
error: mod.getStats().error,
name: mod.getAnalystRecords(1)[0].name,
};
}),
ids,
);
console.log(kind, JSON.stringify(result));
for (const [i, layer] of result.entries()) {
assert.ok(
layer.same &&
!layer.error &&
layer.lod.active > 0 &&
layer.lod.active <= layer.lod.budgetLimit,
);
assert.equal(layer.count, dataset[i].count);
assert.equal(layer.name, dataset[i].name);
}
assert.deepEqual(
reads,
counts,
'Do not reload bundled data on scene handoff',
);
await strict.passed(
p,
ids,
'same model, every bundled record, bounded LOD stems, no file read again',
result.map((r) => r.count),
);
if (
kind !== 'navara' ||
(await p.evaluate(() => window.qaSelections.length))
)
await strict.passed(
p,
'local-datacenters',
'the clicked datacenter stays selected across the switch',
);
// Click-picking on this engine: a dam, then the datacenter again, each
// from the starting view (a selection flies the camera to it).
await home();
await clickDam();
await strict.passed(p, 'local-dams', 'a click on a dam selects it');
await home();
await clickSite();
await strict.passed(
p,
'local-datacenters',
'a click on the datacenter selects it',
);
}
assert.equal(counts.datacenters, 1);
assert.equal(counts.dams, 1);
assert.deepEqual(errors, []);
console.log(
'PASS complete bundled datasets, bounded stems, retained selection and one read per file',
);
});
+8 -41
View File
@@ -6,18 +6,15 @@
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
* durations, so held frames last as long as they did on screen.
* - `installFrameProbe(page)` measures every animation frame in the page:
* the requestAnimationFrame interval and, for rendered frames, the
* CPU time between engine preUpdate and postRender events.
* the requestAnimationFrame interval and, for frames Cesium rendered, the
* CPU time between `scene.preUpdate` and `scene.postRender`.
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
* zoom rather than teleporting it.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
import { execFileSync } from 'node:child_process';
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
export function percentile(values, p) {
@@ -28,7 +25,7 @@ export function percentile(values, p) {
];
}
/** Summarize probe frames: rAF interval and engine render CPU time (ms). */
/** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */
export function frameStats(frames) {
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
@@ -52,17 +49,16 @@ export function frameStats(frames) {
export async function installFrameProbe(page) {
await page.evaluate(() => {
if (window.__gevFrameProbe) return;
const { viewer, engine } = window.__godsEyeView;
const events = viewer?.scene ?? engine.frames;
const { viewer } = window.__godsEyeView;
const probe = { frames: [], started: 0 };
window.__gevFrameProbe = probe;
let last = performance.now();
let renderStart = null;
let pendingRender = null;
events.preUpdate.addEventListener(() => {
viewer.scene.preUpdate.addEventListener(() => {
renderStart = performance.now();
});
events.postRender.addEventListener(() => {
viewer.scene.postRender.addEventListener(() => {
if (renderStart !== null) pendingRender = performance.now() - renderStart;
renderStart = null;
});
@@ -102,34 +98,6 @@ export function pageNow(page) {
* @param {number} seconds Flight duration; 0 jumps.
*/
export async function moveCamera(page, view, seconds) {
if (
await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara')
) {
await page.evaluate(
async (v, duration) => {
const geo = await import('/src/geo/index.js');
const camera = window.__godsEyeView.engine.camera;
camera.cancelFlight();
await new Promise((resolve) =>
camera.flyTo({
destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height),
orientation: {
heading: ((v.heading ?? 0) * Math.PI) / 180,
pitch: ((v.pitch ?? -45) * Math.PI) / 180,
roll: 0,
},
duration,
maximumHeight: Math.max(v.height, camera.height()),
complete: resolve,
cancel: resolve,
}),
);
},
view,
seconds,
);
return;
}
await page.evaluate(
(v, duration) =>
new Promise((resolve) => {
@@ -223,8 +191,7 @@ export async function createScreencast(page, dir, options = {}) {
const list = path.join(dir, 'frames.txt');
fs.writeFileSync(list, `${lines.join('\n')}\n`);
const clip = path.join(dir, 'clip.mp4');
// Keep servicing Puppeteer's request interception while encoding.
await execFileAsync('ffmpeg', [
execFileSync('ffmpeg', [
'-y',
'-loglevel',
'error',
+20 -34
View File
@@ -4,11 +4,7 @@
* legibility over bright ground.
*
* Run:
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara]
*
* Engine-neutral: camera, tiles and layers go through the globe engine and the
* QA probe; an engine without the flights/satellites layers (Navara today)
* shoots the scenes without a contact field and says so.
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after
*
* Every scene pins its own camera pose AND substitutes a deterministic
* synthetic contact field for the live pollers, so a `before` run and an
@@ -40,12 +36,7 @@ const getOpt = (name, fallback) => {
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
};
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
const APP_URL = (() => {
const url = new URL(getOpt('--url', 'http://localhost:4244'));
url.searchParams.set('engine', ENGINE);
return url.href;
})();
const APP_URL = getOpt('--url', 'http://localhost:4244');
const TAG = getOpt('--tag', 'after');
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
const SCENE_FILTER = getOpt('--scenes', '')
@@ -214,8 +205,11 @@ async function settle(page) {
while (Date.now() - started < SETTLE_MAX_MS) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const state = await page.evaluate(() => {
const { engine } = window.__godsEyeView;
return { tilesLoaded: engine.surface.tilesLoaded(), pending: 0 };
const { viewer, tileset } = window.__godsEyeView;
return {
tilesLoaded: tileset?.tilesLoaded !== false,
pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0,
};
});
const settled = state.tilesLoaded && state.pending === 0;
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
@@ -245,7 +239,7 @@ async function main() {
...(executablePath ? { executablePath } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox',
'--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
'--use-gl=angle', '--use-angle=swiftshader',
'--disable-dev-shm-usage', '--disable-web-security',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1280,800',
@@ -260,7 +254,7 @@ async function main() {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
{ timeout: 60_000, polling: 100 },
);
// flyToAustin arrives ~500 ms after init; let it start and land before any
@@ -274,16 +268,12 @@ async function main() {
for (const scene of selected) {
process.stdout.write(` ${scene.id} … `);
const fieldShown = await page.evaluate(async (spec) => {
const { engine, dataManager, styleManager, layers } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
const Cartesian3 = Object.assign(
(x, y, z) => geo.cartesian3.create(x, y, z),
{ fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) },
);
await page.evaluate((spec) => {
const { viewer, dataManager, styleManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
const Cartesian3 = viewer.camera.position.constructor;
engine.camera.setView({
viewer.camera.setView({
destination: Cartesian3.fromDegrees(
spec.camera.lon, spec.camera.lat, spec.camera.height,
),
@@ -300,9 +290,10 @@ async function main() {
// the frame comes back with two labels instead of a field. Building
// straight off the camera basis guarantees every contact lands inside
// the keyhole, at a known depth, in both the before and after runs.
const camera = engine.camera.pose();
const halfHeight = Math.tan((camera.fovy ?? 1.0) / 2);
const halfWidth = halfHeight * (camera.aspectRatio || 1.6);
const camera = viewer.camera;
const frustum = camera.frustum;
const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2);
const halfWidth = halfHeight * (frustum.aspectRatio || 1.6);
const basis = {
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z,
@@ -314,7 +305,7 @@ async function main() {
const pointAt = (ndcX, ndcY, distance) => {
const sx = ndcX * halfWidth * distance;
const sy = ndcY * halfHeight * distance;
return Cartesian3(
return new Cartesian3(
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
@@ -356,9 +347,6 @@ async function main() {
? { satellites: build('s'), flights: [], military: [] }
: { flights: build('f'), military: [], satellites: [] };
window.__LABEL_EVIDENCE = assignment;
const fieldLayers = Object.keys(assignment).filter(
(layerId) => assignment[layerId].length,
);
for (const layerId of Object.keys(assignment)) {
const entry = dataManager.layers.get(layerId);
if (entry?.module) {
@@ -374,7 +362,6 @@ async function main() {
densityPct: 100,
allocationStrategy: 'elastic',
});
return fieldLayers.every((layerId) => layers.available(layerId));
}, scene);
const converged = await settle(page);
@@ -412,11 +399,10 @@ async function main() {
const diagnostics = await page.evaluate(
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
);
results.push({ scene: scene.id, engine: ENGINE, converged, fieldShown, ...stats });
results.push({ scene: scene.id, converged, ...stats });
console.log(
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
+ `${converged ? '' : ' (tiles did not fully settle)'}`
+ `${fieldShown ? '' : ` (no contact field: its layers are not on ${ENGINE})`}`
+ ` [${diagnostics.detectionMode || '?'}]`,
);
}
+24 -98
View File
@@ -3,12 +3,7 @@
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
*
* Run:
* node scripts/qa-labels.mjs --url http://localhost:4173 [--engine=cesium|navara]
*
* Engine-neutral: the camera and frames go through the globe engine and the
* layers through the QA probe (window.__godsEyeView.layers). On an engine
* without the flights, military and satellites layers (Navara today) the
* field checks are SKIPPED and the settings checks still run.
* node scripts/qa-labels.mjs --url http://localhost:4173
*
* The harness replaces only the three detection read APIs in the loaded page.
* It does not enable their network pollers or mutate repository data. The field
@@ -27,12 +22,7 @@ const getOpt = (name, fallback) => {
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
};
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
const APP_URL = (() => {
const url = new URL(getOpt('--url', 'http://localhost:4173'));
url.searchParams.set('engine', ENGINE);
return url.href;
})();
const APP_URL = getOpt('--url', 'http://localhost:4173');
const APP_ORIGIN = new URL(APP_URL).origin;
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/labels');
@@ -84,8 +74,6 @@ function findChromeExecutable() {
}
const results = [];
let skippedChecks = 0;
let skippedField = false;
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
@@ -226,7 +214,7 @@ async function main() {
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
@@ -275,7 +263,7 @@ async function main() {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
{ timeout: 60000, polling: 100 },
);
@@ -283,7 +271,7 @@ async function main() {
// Let that callback start, then cancel it before establishing the global
// measurement camera so startup motion cannot invalidate the sample.
await new Promise((resolve) => setTimeout(resolve, 600));
await page.evaluate(() => window.__godsEyeView.engine.camera.cancelFlight());
await page.evaluate(() => window.__godsEyeView.viewer.camera.cancelFlight());
const stateChecks = await page.evaluate((storageKey) => {
const manager = window.__godsEyeView.styleManager;
@@ -309,62 +297,10 @@ async function main() {
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
);
const fieldLayers = ['flights', 'military', 'satellites'];
const missingLayers = await page.evaluate(
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
fieldLayers,
);
// An engine with flights but not the other field layers: the same 12,000 / 5,200 observations, all as aircraft, so the
// detection lane is measured on the layers the engine has.
let fieldCounts = FIELD_COUNTS;
let normalCounts = NORMAL_COUNTS;
if (missingLayers.length && !missingLayers.includes('flights')) {
const sum = (counts) =>
Object.values(counts).reduce((total, count) => total + count, 0);
fieldCounts = Object.fromEntries(
fieldLayers.map((id) => [
id,
missingLayers.includes(id)
? 0
: id === 'flights'
? sum(FIELD_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + FIELD_COUNTS[other], 0)
: FIELD_COUNTS[id],
]),
);
normalCounts = Object.fromEntries(
fieldLayers.map((id) => [
id,
missingLayers.includes(id)
? 0
: id === 'flights'
? sum(NORMAL_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + NORMAL_COUNTS[other], 0)
: NORMAL_COUNTS[id],
]),
);
console.log(
` [INFO] the field as ${JSON.stringify(fieldCounts)} (no ${missingLayers.join(', ')} on ${ENGINE})`,
);
} else if (missingLayers.length) {
console.log(` [SKIP] the deterministic field checks — no ${missingLayers.join(', ')} on ${ENGINE}`);
skippedChecks += 1;
record(
'console remains clean',
consoleErrors.length === 0,
consoleErrors.slice(0, 3).join(' | ') || '0 errors',
);
skippedField = true;
return;
}
const injected = await page.evaluate(async ({ fieldCounts, normalCounts }) => {
const { engine, dataManager, styleManager } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
// A frame counter on the engine (the samples are one per drawn frame).
window.__LABEL_QA_FRAME = 0;
engine.frames.postRender.addEventListener(() => {
window.__LABEL_QA_FRAME += 1;
});
const Cartesian3 = { fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) };
const injected = await page.evaluate(({ fieldCounts, normalCounts }) => {
const { viewer, dataManager, styleManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
const Cartesian3 = viewer.camera.position.constructor;
const field = { flights: [], military: [], satellites: [] };
const layerIds = Object.keys(field);
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
@@ -392,19 +328,13 @@ async function main() {
window.__LABEL_QA_NORMAL_COUNTS = normalCounts;
for (const layerId of layerIds) {
const entry = dataManager.layers.get(layerId);
if (entry)
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
}
// The global camera, orbiting west to east (the old rotateRight about
// the globe centre at 25 000 km).
let orbitLon = -97;
const placeCamera = () =>
engine.camera.setView({
destination: Cartesian3.fromDegrees(orbitLon, 30, 25000000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
placeCamera();
viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97, 30, 25000000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
styleManager.setDetection({
enabled: true,
densityPct: 100,
@@ -421,14 +351,13 @@ async function main() {
let lastOrbitAt = performance.now();
window.__LABEL_QA_ORBIT = setInterval(() => {
const now = performance.now();
orbitLon -= ((now - lastOrbitAt) * 0.0000012 * 180) / Math.PI;
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
lastOrbitAt = now;
placeCamera();
engine.render.request('qa-labels');
viewer.scene.requestRender();
}, 100);
const sample = () => {
const diagnostics = styleManager.getDetectionDiagnostics();
const frameNumber = window.__LABEL_QA_FRAME;
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
window.__LABEL_QA.lastFrameNumber = frameNumber;
window.__LABEL_QA.frames.push({
@@ -458,7 +387,7 @@ async function main() {
observationCount: Number(dataset.observationCount),
selectedCount: Number(dataset.selectedCount),
fadingCount: Number(dataset.fadingCount),
cameraHeight: engine.camera.height(),
cameraHeight: viewer.camera.positionCartographic.height,
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
@@ -469,7 +398,7 @@ async function main() {
};
requestAnimationFrame(sample);
return Object.fromEntries(layerIds.map((layerId) => [layerId, field[layerId].length]));
}, { fieldCounts, normalCounts });
}, { fieldCounts: FIELD_COUNTS, normalCounts: NORMAL_COUNTS });
record(
'deterministic field contains exactly 12,000 observations',
@@ -483,11 +412,12 @@ async function main() {
// -samples gates supersede it — same fix, deterministic instead of timed.)
await waitForSettledSyntheticField(page);
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
window.__LABEL_QA.solve = [];
window.__LABEL_QA.frames = [];
window.__LABEL_QA.lastRevision = revision;
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
window.__LABEL_QA.startedAt = performance.now();
});
const syntheticSampling = await waitForConclusiveSamples(page, {
@@ -522,11 +452,12 @@ async function main() {
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
);
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
window.__LABEL_QA.solve = [];
window.__LABEL_QA.frames = [];
window.__LABEL_QA.lastRevision = revision;
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
window.__LABEL_QA.startedAt = performance.now();
});
const normalSampling = await waitForConclusiveSamples(page, {
@@ -648,15 +579,10 @@ async function main() {
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
} finally {
await browser.close();
if (skippedField) summarize();
}
summarize();
}
function summarize() {
const failures = results.filter((result) => !result.ok);
console.log(`\n${results.length - failures.length}/${results.length} checks passed${skippedChecks ? `, ${skippedChecks} skipped` : ''} (engine ${ENGINE}).`);
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
if (failures.length) process.exit(1);
}
-517
View File
@@ -1,517 +0,0 @@
import assert from 'node:assert/strict';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]);
const military = process.argv.includes('--military');
const layerId = military ? 'military' : 'flights';
const aircraftId = military ? 'aaa052' : '71be26';
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2' +
(military ? '&l=m&lo=f.e.1_f.u.aaa052' : '&l=f&lo=f.e.1_f.t.71be26');
// The Command Dock, and the Globe Engine picker in it, is hidden in cockpit
// mode: no person can pick an engine there, so the cockpit switches below go
// through the select element; the real picker is used once the cockpit is
// left.
const HIDDEN_PICKER = 'the Globe Engine picker is hidden in cockpit mode';
const strict = createStrict({ origin: url.origin, layers: [layerId] });
await strict.run({}, async (browser) => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setViewport({ width: 1280, height: 720 });
await page.evaluateOnNewDocument(
(layerId, aircraftId) => {
window.__qaAircraft = { layerId, aircraftId };
const fetch = window.fetch.bind(window);
window.__flightPolls = 0;
const interval = window.setInterval.bind(window);
window.setInterval = function (callback, delay, ...args) {
if (typeof callback !== 'function')
return interval(callback, delay, ...args);
const id = interval(
function (...values) {
if (window.__handoff?.timer === id) window.__handoff.ticks++;
return callback.apply(this, values);
},
delay,
...args,
);
return id;
};
window.fetch = (input, options) => {
const path = new URL(input.url ?? input, document.baseURI).pathname;
const now = Math.floor(Date.now() / 1000);
if (path === '/api/flights') {
if (layerId === 'flights') window.__flightPolls++;
return Promise.resolve(
Response.json({
time: now,
states: [
[
'71be26',
'GEV123',
'QA',
now,
now,
-125.5779,
40.2539,
9700,
false,
200,
45,
0,
null,
9700,
null,
false,
0,
],
],
}),
);
}
if (path === '/api/military') {
if (layerId === 'military') window.__flightPolls++;
return Promise.resolve(
Response.json({
now: Date.now(),
ac: [
{
hex: 'aaa052',
flight: 'GEVMIL',
lon: -125.5779,
lat: 40.2539,
alt_geom: 31825,
alt_baro: 31825,
gs: 389,
track: 45,
seen: 0,
seen_pos: 0,
dbFlags: 1,
type: 'adsb_icao',
},
],
}),
);
}
if (path === '/api/flights/track')
return Promise.resolve(Response.json({ path: [] }));
if (path.startsWith('/api/flights/'))
return Promise.resolve(Response.json({}));
return fetch(input, options);
};
},
layerId,
aircraftId,
);
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
const ready = (kind) =>
page.waitForFunction(
(engine) =>
window.__godsEyeView?.engine?.kind === engine &&
!document.querySelector('#globe-engine')?.disabled &&
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame'),
{ timeout: 90000 },
kind,
);
await ready('navara');
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
await page.waitForSelector('#cockpit-entry', { visible: true });
await page.click('#cockpit-entry');
await page.waitForFunction(
() => window.__godsEyeView.styleManager.getCockpitState().active,
);
await page.evaluate(() => {
const { dataManager, styleManager, tileset } = window.__godsEyeView;
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
throw Error('Keyless preview required');
styleManager.cockpitView.setVisionMode('thermal');
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
window.__handoff = {
dataManager,
entry,
row: document.querySelector(
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
),
timer: entry.intervalId,
ticks: 0,
model: entry.module,
pollCount: window.__flightPolls,
timestamp: entry.module.getStats().lastUpdate,
};
});
// Refuse the first Cesium canvas. The application must roll back with the
// original aircraft still in the cockpit, then allow an ordinary retry.
await page.evaluate(() => {
const original = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
if (String(type).includes('webgl')) {
window.__refusedContext = true;
throw new Error('QA renderer startup failure');
}
return original.call(this, type, ...args);
};
window.__restoreContext = () => {
HTMLCanvasElement.prototype.getContext = original;
};
});
const historyLength = await page.evaluate(() => history.length);
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
await page.waitForFunction(() => window.__refusedContext === true);
await ready('navara');
// A failed switch leaves the session usable on the engine that worked and
// adds no history entry.
const survived = (kind) =>
page.evaluate(
(kind, length) => {
const { dataManager, engine, styleManager } = window.__godsEyeView;
return (
engine.kind === kind &&
new URL(location.href).searchParams.get('engine') === kind &&
history.length === length &&
dataManager === window.__handoff.dataManager &&
dataManager.layers.get(window.__qaAircraft.layerId) ===
window.__handoff.entry &&
styleManager.getCockpitState().active &&
styleManager.getCockpitState().visionMode === 'thermal' &&
styleManager.getAircraftTrackingTarget()?.id ===
window.__qaAircraft.aircraftId
);
},
kind,
historyLength,
);
await page.evaluate(() => window.__restoreContext());
await strict.check(
page,
layerId,
'cold Cesium failure rolls back to Navara: same session, cockpit, aircraft, no history entry',
await survived('navara'),
);
console.log(
'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history',
);
for (const kind of ['cesium', 'navara']) {
await strict.selectEngine(page, kind, HIDDEN_PICKER);
await ready(kind);
const result = await page.evaluate(() => {
const { dataManager, engine, styleManager } = window.__godsEyeView;
const before = window.__handoff;
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
const cockpit = styleManager.getCockpitState();
return {
sameOwner:
before.dataManager === dataManager &&
before.entry === entry &&
before.model === entry.module,
sameRow:
Boolean(before.row) &&
before.row ===
document.querySelector(
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
),
sameTimer:
before.timer === entry.intervalId && entry.intervalId != null,
sourcePolls: window.__flightPolls - before.pollCount,
timerTicks: before.ticks,
stamp: entry.module.getStats().lastUpdate,
initialStamp: before.timestamp,
id: cockpit.subject?.id,
active: cockpit.active,
mode: cockpit.visionMode,
hud: !document.querySelector('#cockpit-hud').hidden,
drawn: entry.module
.qaDrawn()
.some((row) => row.id === window.__qaAircraft.aircraftId),
height: engine.camera.height(),
};
});
assert.ok(
result.sameOwner &&
result.sameTimer &&
result.sameRow &&
result.active &&
result.hud &&
result.drawn,
JSON.stringify(result),
);
assert.equal(result.id, aircraftId);
assert.equal(result.mode, 'thermal');
assert.ok(
result.sourcePolls <= result.timerTicks,
'switch must not add a source request',
);
if (result.timerTicks === 0) {
assert.equal(result.stamp, result.initialStamp);
}
await strict.passed(
page,
layerId,
'same manager entry, model, row, poll timer, cockpit, thermal mode, HUD and drawn aircraft',
result,
);
// Polling goes on after the switch: the same timer ticks and each tick
// reads the source once (no tick may be skipped, none added).
const polls = await page.evaluate(async (layerId) => {
const entry = window.__godsEyeView.dataManager.layers.get(layerId);
const every =
Number(entry.module.refreshInterval) ||
Number(entry.module.updateInterval);
const start = {
polls: window.__flightPolls,
ticks: window.__handoff.ticks,
stamp: entry.module.getStats().lastUpdate,
};
const deadline = Date.now() + 2.5 * every + 5000;
while (window.__handoff.ticks - start.ticks < 2 && Date.now() < deadline)
await new Promise((resolve) => setTimeout(resolve, 250));
// Let the last tick's request land.
await new Promise((resolve) => setTimeout(resolve, 1500));
return {
every,
ticks: window.__handoff.ticks - start.ticks,
polls: window.__flightPolls - start.polls,
updated: entry.module.getStats().lastUpdate !== start.stamp,
sameTimer: entry.intervalId === window.__handoff.timer,
};
}, layerId);
await strict.check(
page,
layerId,
'polling continues after the switch: two timer ticks, one source read each, fresh data',
polls.every > 0 &&
polls.ticks >= 2 &&
polls.polls === polls.ticks &&
polls.updated &&
polls.sameTimer,
polls,
);
await page.waitForFunction(
() => Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000,
);
console.log(
`PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`,
);
}
// The other direction, warm: the parked Navara renderer fails to resume on
// the way back from Cesium. The session rolls back to Cesium, then an
// ordinary retry starts Navara cold.
await page.evaluate(() => {
window.__parkedNavara = window.__godsEyeView.engine;
});
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
await ready('cesium');
const lengthOnCesium = await page.evaluate(() => {
const request = window.__parkedNavara.render.request;
window.__parkedNavara.render.request = function (...args) {
window.__parkedNavara.render.request = request;
window.__refusedResume = true;
throw new Error('QA parked renderer failure');
};
return history.length;
});
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
await page.waitForFunction(() => window.__refusedResume === true);
await ready('cesium');
assert.ok(
(await page.evaluate(
(length) => history.length === length,
lengthOnCesium,
)) &&
(await page.evaluate(() => {
const { styleManager } = window.__godsEyeView;
return (
styleManager.getCockpitState().active &&
styleManager.getAircraftTrackingTarget()?.id ===
window.__qaAircraft.aircraftId &&
new URL(location.href).searchParams.get('engine') === 'cesium'
);
})),
'a failed warm Navara resume must leave Cesium usable without a history entry',
);
await strict.passed(
page,
layerId,
'warm Navara failure rolls back to Cesium: same aircraft and cockpit, no history entry',
);
console.log(
'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit',
);
await page.evaluate(() => {
window.__parkedCesium = window.__godsEyeView.engine;
});
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
await ready('navara');
assert.ok(
await page.evaluate(() => {
const { engine, styleManager } = window.__godsEyeView;
return (
engine !== window.__parkedNavara &&
styleManager.getCockpitState().active &&
styleManager.getAircraftTrackingTarget()?.id ===
window.__qaAircraft.aircraftId
);
}),
'the retry starts a new Navara renderer on the same session',
);
console.log('PASS retry after the warm failure started Navara cold');
// Both engines fail: the warm Cesium resume and the warm Navara recovery.
// No engine runs, but the error screen offers the engine picker, and a
// retry starts Cesium cold on the same session without a reload.
const lengthBeforeDoubleFailure = await page.evaluate(() => {
window.__failResume = true;
for (const engine of [window.__godsEyeView.engine, window.__parkedCesium]) {
const request = engine.render.request;
engine.render.request = function (reason, ...args) {
if (reason === 'engine-resume' && window.__failResume) {
window.__refusedResumes = (window.__refusedResumes ?? 0) + 1;
throw new Error('QA parked renderer failure');
}
return request.call(this, reason, ...args);
};
}
window.__startDocument = document;
return history.length;
});
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
await page
.waitForSelector('#loading-screen .engine-switch-retry button', {
visible: true,
timeout: 90000,
})
.catch(async (error) => {
console.log(
'no retry picker',
JSON.stringify(
await page.evaluate(() => ({
refused: window.__refusedResumes,
engine: window.__godsEyeView?.engine?.kind ?? null,
retry: !!document.querySelector('.engine-switch-retry'),
loading: document.querySelector('#loading-screen')?.className,
status: document.querySelector('#loading-screen .loader-status')
?.textContent,
address: location.search,
})),
),
);
throw error;
});
assert.ok(
await page.evaluate(
(length) =>
window.__refusedResumes === 2 &&
// No globe runs (the page's debug object may stay, without one).
!window.__godsEyeView?.engine &&
history.length === length &&
!document
.querySelector('#loading-screen')
.classList.contains('hidden') &&
/QA parked renderer failure/.test(
document.querySelector('#loading-screen .loader-status').textContent,
),
lengthBeforeDoubleFailure,
),
'a failed recovery shows its error and the engine picker ' +
JSON.stringify(
await page.evaluate(() => ({
refused: window.__refusedResumes,
app: !!window.__godsEyeView,
engine: window.__godsEyeView?.engine?.kind ?? null,
history: history.length,
loading: document.querySelector('#loading-screen')?.className,
status: document.querySelector('#loading-screen .loader-status')
?.textContent,
})),
),
);
await page.evaluate(() => {
window.__failResume = false;
});
await page.click(
'#loading-screen .engine-switch-retry [data-engine="cesium"]',
);
await ready('cesium');
assert.ok(
await page.evaluate(() => {
const { dataManager, styleManager } = window.__godsEyeView;
return (
window.__startDocument === document &&
!document.querySelector('.engine-switch-retry') &&
dataManager === window.__handoff.dataManager &&
dataManager.layers.get(window.__qaAircraft.layerId) ===
window.__handoff.entry &&
styleManager.getCockpitState().active &&
styleManager.getAircraftTrackingTarget()?.id ===
window.__qaAircraft.aircraftId &&
new URL(location.href).searchParams.get('engine') === 'cesium'
);
}),
'the retry restores the same session on Cesium',
);
await strict.passed(
page,
layerId,
'after both engines fail, the retry restores the same session',
);
console.log(
'PASS failed recovery offered the picker; the retry restored the session on Cesium',
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.controlCockpit('exit'),
);
await page.waitForFunction(
() => !window.__godsEyeView.styleManager.getCockpitState().active,
);
// Out of the cockpit the dock shows the picker again: a switch with
// real input keeps the same entry and timer, the aircraft drawn, and the
// source polled on its timer.
await page.evaluate(() => {
window.__handoff.ticks = 0;
window.__handoff.pollCount = window.__flightPolls;
window.__handoff.timer = window.__godsEyeView.dataManager.layers.get(
window.__qaAircraft.layerId,
).intervalId;
});
await strict.pick(page, 'navara');
await ready('navara');
const after = await page.evaluate(async () => {
const { dataManager } = window.__godsEyeView;
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
const every =
Number(entry.module.refreshInterval) ||
Number(entry.module.updateInterval);
const deadline = Date.now() + 2.5 * every + 5000;
while (window.__handoff.ticks < 1 && Date.now() < deadline)
await new Promise((resolve) => setTimeout(resolve, 250));
await new Promise((resolve) => setTimeout(resolve, 1500));
return {
same:
dataManager === window.__handoff.dataManager &&
entry === window.__handoff.entry,
timer: entry.intervalId === window.__handoff.timer,
drawn: entry.module
.qaDrawn()
.some((row) => row.id === window.__qaAircraft.aircraftId),
ticks: window.__handoff.ticks,
polls: window.__flightPolls - window.__handoff.pollCount,
};
});
await strict.check(
page,
layerId,
'picker switch keeps the entry, timer and drawn aircraft; polling continues',
after.same &&
after.timer &&
after.drawn &&
after.ticks >= 1 &&
after.polls === after.ticks,
after,
);
assert.deepEqual(errors, []);
});
-145
View File
@@ -1,145 +0,0 @@
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const strict = createStrict({ origin: base.origin, layers: ['local-adsb'] });
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b);
await p.evaluateOnNewDocument(() => {
const raw = window.fetch;
window.fetch = function (input, ...args) {
const url = new URL(input?.url ?? input, location.href);
if (url.pathname === '/api/local-receivers/aircraft') {
const at = Date.now();
// Each poll reports the aircraft a little further north.
const n = (window.qaAdsbPolls = (window.qaAdsbPolls ?? 0) + 1);
return Promise.resolve(
Response.json({
configured: true,
generatedAt: at,
records: [
{
icao: 'abc123',
callsign: 'LOCAL1',
lat: 41.534 + 0.0002 * n,
lon: -90.507,
altitudeFt: 11000,
groundSpeedKt: 180,
trackDeg: 5,
lastPositionAt: at,
lastMessageAt: at,
messageCount: 12 + n,
band: '1090',
source: 'decoder',
},
],
}),
);
}
return raw.call(this, input, ...args);
};
window.qaSelections = [];
window.addEventListener('gev:entity-selected', ({ detail }) =>
window.qaSelections.push(detail.id),
);
});
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { dataManager, tileset } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
const mod = dataManager.layers.get('local-adsb').module;
window.qaAdsb = {
mod,
receiver: mod.receiver,
feeds: mod.feeds,
starts: 0,
stops: 0,
closes: 0,
};
for (const [obj, key, counter] of [
[mod.receiver, 'destroy', 'closes'],
[mod.feeds, 'start', 'starts'],
[mod.feeds, 'stop', 'stops'],
]) {
const original = obj[key].bind(obj);
obj[key] = (...args) => {
window.qaAdsb[counter]++;
return original(...args);
};
}
await dataManager.setEnabled('local-adsb', true, { origin: 'user' });
});
await p.waitForFunction(
() => window.qaAdsb.mod.getAllPositions().length === 1,
);
// The selection hook the selectable layers share, by the row's own id.
assert.ok(
await p.evaluate(() =>
window.qaAdsb.mod.selectById(window.qaAdsb.mod.getAllPositions()[0].id),
),
);
for (const kind of ['cesium', 'navara']) {
await p.evaluate(() => {
window.qaSelections = [];
});
await strict.switchTo(p, kind);
await p.waitForFunction(() =>
window.qaSelections.includes('local-adsb:abc123'),
);
const result = await p.evaluate(() => {
const q = window.qaAdsb,
manager = window.__godsEyeView.dataManager;
const mod = manager.layers.get('local-adsb').module;
return [
manager.isEnabled(mod.id),
q.mod === mod,
q.receiver === mod.receiver,
q.feeds === mod.feeds,
mod.getAllPositions()[0]?.id,
q.starts,
q.stops,
q.closes,
];
});
assert.deepEqual(result, [true, true, true, true, 'abc123', 1, 0, 0]);
await strict.passed(
p,
'local-adsb',
'same decoder, receiver and feeds, one feed start, selection republished',
);
// Updates continue on the new engine: further polls move the aircraft.
const before = await p.evaluate(() => ({
polls: window.qaAdsbPolls,
lat: window.qaAdsb.mod.getAnalystRecords()[0]?.lat,
stamp: window.qaAdsb.mod.getStats().lastUpdate,
}));
await p.waitForFunction(
(before) =>
window.qaAdsbPolls >= before.polls + 2 &&
window.qaAdsb.mod.getAnalystRecords()[0]?.lat > before.lat &&
window.qaAdsb.mod.getStats().lastUpdate > before.stamp,
{ timeout: 15000 },
before,
);
await strict.passed(
p,
'local-adsb',
'receiver polls continue and move the selected aircraft',
before,
);
}
assert.deepEqual(errors, []);
console.log(
'PASS retained local decoder, receiver, marker and selection in both scenes',
);
});
-117
View File
@@ -1,117 +0,0 @@
/** Install before navigation, without intercepting workers or ordinary tiles. */
export async function blockPaidQaMaps(page, { allowPaidMaps = false } = {}) {
if (allowPaidMaps) return;
const session = await page.createCDPSession();
await session.send('Network.enable');
await session.send('Network.setBlockedURLs', {
urls: [
'*://tile.googleapis.com/*',
'*://*.googleapis.com/*3dtiles*',
'*://api.cesium.com/v1/assets/2275207/*',
'*/api/google/tiles*',
],
});
return session;
}
/**
* Free public tile hosts the strict acceptance lets a page ask for. The page
* never reaches them: the strict harness answers them from a disk cache
* (scripts/qa/tileCache.mjs), and Node fetches a missing tile once, politely.
* OpenStreetMap raster tiles, the keyless re:earth terrain, the EOX
* Sentinel-2 cloudless mosaic and Esri World Imagery (the keyless Cesium
* base imagery and the Navara startup imagery).
*/
export const STRICT_TILE_HOSTS = Object.freeze([
'tile.openstreetmap.org',
'terrain.reearth.land',
'tiles.maps.eox.at',
'services.arcgisonline.com',
]);
/**
* Requests every keyless page makes on its own, answered by the strict
* harness exactly as the keyless preview would (or with an empty stylesheet
* for Google Fonts), so they reach neither the preview's paid routes nor
* Google. Any other request under a paid route or host stays blocked.
* Keys are URLPattern strings relative to the preview origin or absolute.
*/
export const KEYLESS_PROBES = Object.freeze([
{
pattern: '/api/openai/hud-summary/status',
method: 'GET',
status: 200,
contentType: 'application/json',
body: '{"configured":false}',
},
{ pattern: '/api/google/tiles-token', method: 'GET', status: 204 },
{ pattern: '/api/realtime/debug-log', method: 'POST', status: 204 },
{
pattern: 'https://fonts.googleapis.com/css2*',
method: 'GET',
status: 200,
contentType: 'text/css',
body: '/* Google Fonts withheld by the strict QA policy */',
},
]);
/** A probe's absolute URLPattern string. */
export const probePattern = (origin, probe) =>
probe.pattern.startsWith('/')
? new URL(origin).origin + probe.pattern
: probe.pattern;
/** Paid or keyed services: blocked first, whatever else a scenario allows. */
export const PAID_HOST_PATTERNS = Object.freeze([
'*://*.googleapis.com/*',
'*://googleapis.com/*',
'*://*.google.com/*',
'*://google.com/*',
'*://maps.google.*/*',
'*://*.gstatic.com/*',
'*://*.cesium.com/*',
'*://cesium.com/*',
]);
/** Preview routes that spend a key (OpenAI, Realtime, Google proxies). */
export const PAID_PREVIEW_PATHS = Object.freeze([
'/api/openai/*',
'/api/realtime/*',
'/api/google/*',
]);
/**
* The ordered Network.setBlockedURLs patterns of the strict policy (first
* match wins): the keyless probes the harness answers are let through to
* it, paid hosts and paid preview routes are blocked, then the
* preview origin, the scenario's fixture URLs and the free tile hosts are
* allowed, then everything else is blocked.
*
* @param {string} origin The preview origin, e.g. http://127.0.0.1:4208
* @param {{fixtures?: string[], tileHosts?: readonly string[]}} [options]
* `fixtures` are URLPattern strings for requests the scenario answers.
*/
export function strictBlockPatterns(
origin,
{ fixtures = [], tileHosts = STRICT_TILE_HOSTS } = {},
) {
const preview = new URL(origin).origin;
return [
...KEYLESS_PROBES.map((probe) => ({
urlPattern: probePattern(origin, probe),
block: false,
})),
...PAID_HOST_PATTERNS.map((urlPattern) => ({ urlPattern, block: true })),
...PAID_PREVIEW_PATHS.map((path) => ({
urlPattern: preview + path,
block: true,
})),
{ urlPattern: preview + '/*', block: false },
...fixtures.map((urlPattern) => ({ urlPattern, block: false })),
...tileHosts.map((host) => ({
urlPattern: `https://${host}/*`,
block: false,
})),
{ urlPattern: '*://*:*/*', block: true },
];
}
-83
View File
@@ -1,83 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
blockPaidQaMaps,
KEYLESS_PROBES,
probePattern,
strictBlockPatterns,
} from './qa-map-policy.mjs';
test('routine QA blocks direct Google tiles, hosted Google assets and token fallback', async () => {
const calls = [];
const page = {
createCDPSession: async () => ({
send: async (...args) => calls.push(args),
}),
};
await blockPaidQaMaps(page);
assert.equal(calls[0][0], 'Network.enable');
assert.equal(calls[1][0], 'Network.setBlockedURLs');
assert.ok(calls[1][1].urls.includes('*://tile.googleapis.com/*'));
assert.ok(
calls[1][1].urls.includes('*://api.cesium.com/v1/assets/2275207/*'),
);
assert.ok(calls[1][1].urls.includes('*/api/google/tiles*'));
});
test('a necessary 3D-specific check must explicitly opt out of the request block', async () => {
await blockPaidQaMaps(
{ createCDPSession: () => assert.fail('unexpected block') },
{ allowPaidMaps: true },
);
});
test('the strict policy blocks paid hosts and routes first and everything unlisted last', () => {
const patterns = strictBlockPatterns('http://127.0.0.1:4208', {
fixtures: ['https://tiles.dontgetflocked.com/*'],
});
const decide = (url) =>
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
?.block;
for (const url of [
'https://tile.googleapis.com/v1/3dtiles/root.json',
'https://maps.googleapis.com/maps/api/js',
'https://fonts.gstatic.com/s/inter.woff2',
'https://api.cesium.com/v1/assets/1/endpoint',
'https://assets.ion.cesium.com/google-credit.png',
'http://127.0.0.1:4208/api/openai/hud-summary',
'http://127.0.0.1:4208/api/realtime/token',
'http://127.0.0.1:4208/api/google/nearby-places?lat=1',
'https://example.com/anything',
'wss://example.com/socket',
])
assert.equal(decide(url), true, url);
for (const url of [
'http://127.0.0.1:4208/assets/index.js',
'http://127.0.0.1:4208/api/flights',
'https://tiles.dontgetflocked.com/cameras-us-hourly/1/2/3.mvt',
'https://tile.openstreetmap.org/1/2/3.png',
'https://terrain.reearth.land/cesium-mesh/layer.json',
'https://tiles.maps.eox.at/wmts/1.0.0/s2/1/2/3.jpg',
])
assert.equal(decide(url), false, url);
});
test('the keyless probes are let through only to their exact routes', () => {
const patterns = strictBlockPatterns('http://127.0.0.1:4208');
const decide = (url) =>
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
?.block;
for (const probe of KEYLESS_PROBES) {
const url = probePattern('http://127.0.0.1:4208', probe).replace(
/\*$/,
'?family=Inter',
);
assert.equal(decide(url), false, url);
}
assert.equal(
decide('http://127.0.0.1:4208/api/openai/hud-summary'),
true,
'the paid summary itself stays blocked',
);
assert.equal(decide('https://fonts.googleapis.com/icon?family=x'), true);
});
+8 -19
View File
@@ -316,9 +316,9 @@ try {
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
}));
check(
'exact six-source presentation; the retired left Map Stack panel is gone',
'exact five-source presentation; the retired left Map Stack panel is gone',
JSON.stringify(presentation.ids) === JSON.stringify([
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'sentinel2', 'osm',
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
]) && !presentation.retiredPanel,
JSON.stringify(presentation),
);
@@ -338,16 +338,12 @@ try {
while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
// The base imagery the controller draws now (it is handed to the globe
// engine, src/maps/controller.js).
const liveProvider = () =>
controller._liveBaseProvider?.() ?? controller._activeImageryProvider;
const provider = liveProvider();
const provider = controller._activeImageryProvider;
const before = {
activeId: controller.getActiveId(),
creditVisible: document.body.innerText.includes('Powered by Esri'),
globeShown: styleManager.viewer.scene.globe.show,
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
hasLayer: Boolean(controller._imageryLayer),
};
provider?.errorEvent?.raiseEvent?.({ timesRetried: 0 });
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -374,7 +370,7 @@ try {
lastError: controller.getState().lastError,
creditVisible: document.body.innerText.includes('Powered by Esri'),
globeShown: styleManager.viewer.scene.globe.show,
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
hasLayer: Boolean(controller._imageryLayer),
active: [...document.querySelectorAll('.map-stack-chip')]
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
.map((chip) => chip.dataset.stackId),
@@ -568,16 +564,9 @@ try {
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
// Visual Styles precede the Map Source section: its globe-engine picker
// and tiles (OSM is the last). Keep the existing opening handoff to
// selected OSM, then use the user's real Shift+Tab path into styles: one
// press per control of that section.
const mapSourceControls = await page.evaluate(
() =>
document.querySelectorAll('.map-stack-chip').length +
(document.getElementById('globe-engine') ? 1 : 0),
);
await pressTabs(mapSourceControls, true);
// Visual Styles precede the five Map Source tiles. Keep the existing opening
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
await pressTabs(5, true);
const noirFocus = await styleFocusMetrics();
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env node
// Annotations on both engines, under the strict network policy: the same
// board (a hand-drawn area, a pin in view and a pin on the far side of the
// globe) is drawn on Navara, survives a switch to Cesium and back, and on
// each engine the draped area paints amber pixels on the canvas, the near
// pin's callout shows and the far pin is culled at the horizon.
//
// node scripts/qa-navara-annotations.mjs http://127.0.0.1:<PORT>/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import sharp from 'sharp';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
const LAT = 37.7749;
const LON = -122.4194;
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0';
base.hash = `v=2&lat=${LAT}&lon=${LON}&alt=7000&heading=0&pitch=-90&roll=0&map=osm&l=`;
const strict = createStrict({ origin: base.origin, layers: ['annotations'] });
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations';
fs.mkdirSync(SHOTS, { recursive: true });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// The area: a box around the view centre, clear of the panels.
const RING = [
[LON - 0.012, LAT - 0.009],
[LON + 0.012, LAT - 0.009],
[LON + 0.012, LAT + 0.009],
[LON - 0.012, LAT + 0.009],
];
await strict.run(
{
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
},
async (browser) => {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 860 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base.href);
await waitForEngine(page, 'navara');
assert.equal(
await page.evaluate(() =>
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
),
false,
'keyless preview required',
);
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
const canvasAmber = async () => {
await page.addStyleTag({
content: '.gev-screen-whiteboard{visibility:hidden!important}',
});
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
await wait(700);
const { data, info } = await sharp(
await page.screenshot({
clip: { x: 340, y: 180, width: 600, height: 500 },
}),
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
await page.evaluate(() =>
document
.querySelectorAll('style')
.forEach(
(style) =>
style.textContent.includes('gev-screen-whiteboard{visibility') &&
style.remove(),
),
);
let amber = 0;
for (let i = 0; i < data.length; i += info.channels) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
amber++;
}
return amber;
};
/** The callout texts the page board shows, and those it hides. */
const board = () =>
page.evaluate(() => {
const out = { shown: [], hidden: [] };
for (const group of document.querySelectorAll(
'.gev-screen-whiteboard-svg g.gev-anno',
)) {
const text = group.querySelector('.gev-anno-text')?.textContent;
if (!text) continue;
const hidden =
group.style.display === 'none' ||
getComputedStyle(group).display === 'none';
(hidden ? out.hidden : out.shown).push(text);
}
out.shown.sort();
out.hidden.sort();
return out;
});
let round = 0;
const drawn = await page.evaluate(
async (ring, lat, lon) => {
const annotations = window.__gevAnnotations;
window.qaBoard = annotations;
const result = await annotations.annotate(
[
{
type: 'area',
manual: true,
ring,
label: 'QA Area',
color: 'amber',
},
{
type: 'pin',
manual: true,
latitude: lat + 0.004,
longitude: lon + 0.006,
label: 'QA Near',
color: 'green',
},
{
type: 'pin',
manual: true,
latitude: -lat,
longitude: lon + 180,
label: 'QA Far',
color: 'cyan',
},
],
{ persist: true, flyTo: false },
);
return {
drawn: result.drawn,
attached: annotations.attached,
handle: window.__godsEyeView.annotations === annotations,
};
},
RING,
LAT,
LON,
);
await strict.check(
page,
'annotations',
'the board draws three marks on the attached engine and is the debug handle',
drawn.drawn === 3 && drawn.attached && drawn.handle,
drawn,
);
for (const kind of ['navara', 'cesium', 'navara']) {
if (
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
) {
await strict.switchTo(page, kind);
}
await wait(2500);
const state = await page.evaluate(() => ({
same: window.__gevAnnotations === window.qaBoard,
count: window.qaBoard.count(),
attached: window.qaBoard.attached,
handle: window.__godsEyeView.annotations === window.qaBoard,
}));
await strict.check(
page,
'annotations',
'the same session board keeps its three marks and draws on this engine',
state.same && state.count === 3 && state.attached && state.handle,
state,
);
const seen = await board();
console.log(kind, 'board', JSON.stringify(seen));
await strict.check(
page,
'annotations',
'the near callouts show and the far pin is culled at the horizon',
seen.shown.includes('QA Near') &&
seen.shown.includes('QA Area') &&
!seen.shown.includes('QA Far'),
seen,
);
await page.screenshot({
path: `${SHOTS}/annotations-${kind}-${round}.png`,
});
const amber = await canvasAmber();
// The same view without the marks (hidden from the list).
const setHidden = (hidden) =>
page.evaluate((h) => {
for (const mark of window.qaBoard.list())
window.qaBoard.setHidden(mark.id, h);
}, hidden);
await setHidden(true);
const bare = await canvasAmber();
await setHidden(false);
console.log(kind, 'amber pixels', { bare, amber });
await strict.check(
page,
'annotations',
'the draped area paints amber on the globe canvas',
amber - bare > 400,
{ bare, amber },
);
round++;
}
// Fly-to a mark goes through the engine's camera on Navara.
const flight = await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
const before = engine.camera.height();
const area = window.qaBoard.list().find((m) => m.label === 'QA Area');
const ok = window.qaBoard.frame(area.id);
await new Promise((r) => setTimeout(r, 2600));
return { ok, before, after: engine.camera.height() };
});
await strict.check(
page,
'annotations',
'framing a mark flies the Navara camera to it',
flight.ok && Math.abs(flight.after - flight.before) > 100,
flight,
);
// Hidden from the list: the board and the canvas let it go.
const hidden = await page.evaluate(async () => {
for (const mark of window.qaBoard.list())
window.qaBoard.setHidden(mark.id, true);
await new Promise((r) => setTimeout(r, 600));
return window.qaBoard.list().every((m) => m.hidden);
});
const afterHide = await board();
await strict.check(
page,
'annotations',
'hidden marks leave the board',
hidden && afterHide.shown.length === 0,
afterHide,
);
await page.evaluate(() => window.qaBoard.clear());
assert.deepEqual(errors, []);
console.log(
'PASS annotations drawn, culled and kept across engine switches',
);
},
);
-202
View File
@@ -1,202 +0,0 @@
// The app's Bloom on Navara: the Display bloom glows the
// Navara frame (brighter, more at a higher slider), it sits after the
// overlays and before the visual styles, Cesium's dead zone draws nothing,
// turning it off restores the frame exactly, and on Cesium the scene's own
// bloom stage still runs with the engine left alone. Keyless preview under
// the strict default-deny network policy.
//
// node scripts/qa-navara-bloom.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
capture,
checker,
diff,
freeze,
pickStyle,
pinDaytime,
saveCapture,
stats,
visualPageUrl,
} from './qa/navaraVisual.mjs';
const base = localPreview(process.argv[2]);
const outDir = process.argv
.find((arg) => arg.startsWith('--out='))
?.slice('--out='.length);
const strict = createStrict({ origin: base.origin, layers: [] });
const report = { checks: [] };
const check = checker(report, assert);
const save = (name, pixels) => saveCapture(outDir, name, pixels);
const luma = (s) => 0.299 * s.r + 0.587 * s.g + 0.114 * s.b;
// The Display bloom, as its toggle and slider set it.
const setBloom = (page, enabled, intensityPct) =>
page.evaluate(
(enabled, intensityPct) => {
const a = window.__godsEyeView;
a.styleManager.setBloom({ enabled, intensityPct });
return {
control: a.styleManager.getControlState().bloom,
engine: a.engine.render.bloom?.() ?? null,
pipeline: a.engine.pipeline?.() ?? null,
};
},
enabled,
intensityPct,
);
await strict.run({}, async (browser) => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
// The same sun on every run: the frame's brightness decides what blooms
// and what each style's signature reads.
await pinDaytime(page);
await page.evaluateOnNewDocument(() => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
});
await page.setViewport({ width: 1280, height: 800 });
await page.goto(visualPageUrl(base.origin, 'navara'), {
waitUntil: 'domcontentloaded',
});
await waitForEngine(page, 'navara');
await freeze(page);
const initial = await page.evaluate(() => {
const { engine } = window.__godsEyeView;
return { bloom: engine.render.bloom(), pipeline: engine.pipeline() };
});
check(
'Navara blooms itself and starts with bloom off',
initial.bloom.enabled === false && !initial.pipeline?.bloomShown,
initial,
);
const normal = await capture(page);
save('normal', normal);
const normalStats = stats(normal);
const full = await setBloom(page, true, 200);
check(
'the slider at 200 reaches the engine at full strength',
full.engine.enabled === true &&
full.engine.strength === 1 &&
full.pipeline.bloomShown === true,
full,
);
const order = full.pipeline.passes ?? [];
const at = order.indexOf('gevBloom');
check(
'bloom runs after the scene and overlays, just before the final copy',
at >= 0 &&
order[at + 1] === 'final' &&
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
(key) => !order.includes(key) || order.indexOf(key) < at,
),
order,
);
const glowFull = await capture(page);
save('bloom-200', glowFull);
const fullChange = diff(normal, glowFull);
const fullStats = stats(glowFull);
check(
'bloom at 200 glows the frame brighter',
fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2,
{
...fullChange,
normalLuma: luma(normalStats),
bloomLuma: luma(fullStats),
},
);
await setBloom(page, true, 100);
const glowHalf = await capture(page);
save('bloom-100', glowHalf);
const halfChange = diff(normal, glowHalf);
check(
'a lower slider glows less',
halfChange.mean > 0.5 && halfChange.mean < fullChange.mean,
{ half: halfChange.mean, full: fullChange.mean },
);
const dead = await setBloom(page, true, 10);
const deadFrame = await capture(page);
check(
"Cesium's dead zone: the toggle on, nothing glows",
dead.engine.enabled === false &&
dead.pipeline.bloomShown === false &&
diff(normal, deadFrame).mean === 0,
{ engine: dead.engine, diff: diff(normal, deadFrame) },
);
// With a style: bloom first, then the style, then the final copy.
await setBloom(page, true, 200);
const styled = await pickStyle(page, 'noir', 'normal');
const styledOrder = styled.pipeline?.passes ?? [];
check(
'bloom runs before the visual styles',
styledOrder.indexOf('gevBloom') >= 0 &&
styledOrder.indexOf('gevBloom') + 1 ===
styledOrder.indexOf('gevStyles') &&
styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'),
styledOrder,
);
await pickStyle(page, 'normal', 'noir');
const off = await setBloom(page, false, 200);
const offFrame = await capture(page);
check(
'turning bloom off restores the frame',
off.engine.enabled === false &&
off.pipeline.bloomShown === false &&
diff(normal, offFrame).mean === 0,
{ engine: off.engine, diff: diff(normal, offFrame) },
);
// Cesium keeps its scene bloom stage; its engine has no bloom.
await strict.switchTo(page, 'cesium');
await freeze(page);
await setBloom(page, false, 200);
const cesiumNormal = await capture(page);
const cesium = await setBloom(page, true, 200);
const cesiumStage = await page.evaluate(() => {
const a = window.__godsEyeView;
const stage = a.viewer?.scene?.postProcessStages?.bloom;
return {
setBloom: typeof a.engine.render.setBloom,
enabled: stage?.enabled ?? null,
contrast: stage?.uniforms?.contrast ?? null,
};
});
const cesiumGlow = await capture(page);
save('cesium-bloom-200', cesiumGlow);
check(
"Cesium: its scene's bloom stage glows, the engine draws none",
cesium.engine === null &&
cesiumStage.setBloom === 'undefined' &&
cesiumStage.enabled !== false &&
diff(cesiumNormal, cesiumGlow).mean > 1,
{ stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) },
);
await setBloom(page, false, 200);
const cesiumBack = diff(cesiumNormal, await capture(page));
check(
'Cesium: bloom off restores its frame',
cesiumBack.mean < 0.5,
cesiumBack,
);
check('no page errors', errors.length === 0, errors);
if (outDir)
fs.writeFileSync(
path.join(outDir, 'bloom.json'),
JSON.stringify(report, null, 2),
);
});
-247
View File
@@ -1,247 +0,0 @@
// The cockpit weather clouds on Navara: in cockpit mode with
// the cockpit weather on, Navara's own volumetric clouds take their cover
// from the weather under the camera (clear: none; overcast: a heavy deck),
// with no second canvas or WebGL context; leaving the cockpit gives the look
// its own clouds back. On Cesium the cockpit keeps its own cloud canvas and
// the engine draws none. The weather route is a fixture, the page clock is
// shifted to an Austin midday (the look shows clouds only in daylight), and
// cockpit mode is entered through its event (the cloud controllers listen
// to it; a real cockpit needs a tracked aircraft). Keyless preview under the
// strict default-deny network policy.
//
// node scripts/qa-navara-cockpit-clouds.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
capture,
checker,
diff,
freeze,
saveCapture,
stats,
visualPageUrl,
} from './qa/navaraVisual.mjs';
const base = localPreview(process.argv[2]);
const outDir = process.argv
.find((arg) => arg.startsWith('--out='))
?.slice('--out='.length);
const LOW_VIEW =
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=90&pitch=-8&map=osm&l=';
const MIDDAY_UTC = '2026-07-15T18:00:00Z';
const WEATHER = {
clear: { weatherCode: 0, cloudCoverPct: 0, windKph: 6, visibilityM: 20000 },
overcast: {
weatherCode: 3,
cloudCoverPct: 100,
windKph: 25,
visibilityM: 15000,
},
};
let weather = WEATHER.clear;
const asked = [];
const strict = createStrict({
origin: base.origin,
layers: [],
routes: [
{
pattern: `${base.origin}/api/weather-effects*`,
respond: (request) => {
asked.push(request.url);
return {
contentType: 'application/json',
body: JSON.stringify({ status: 'live', weather }),
};
},
},
],
});
const report = { checks: [] };
const check = checker(report, assert);
const save = (name, pixels) => saveCapture(outDir, name, pixels);
// Clouds grey the frame: the blue sky and the map's coloured lines go.
const saturation = (pixels) => stats(pixels).saturation;
// A few seconds of continuous frames: the clouds converge over frames.
async function converged(page, name) {
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
engine.render.setContinuous(true);
await new Promise((resolve) => setTimeout(resolve, 3000));
engine.render.setContinuous(false);
});
const pixels = await capture(page);
save(name, pixels);
return pixels;
}
const cockpit = (page, active) =>
page.evaluate((active) => {
document.body.classList.toggle('cockpit-mode', active);
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', { detail: { active } }),
);
}, active);
const weatherToggle = (page, enabled) =>
page.evaluate((enabled) => {
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-toggle', { detail: { enabled } }),
);
}, enabled);
const coverage = (page) =>
page.evaluate(
() => window.__godsEyeView.engine.render.cloudCover?.().coverage,
);
await strict.run({}, async (browser) => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.evaluateOnNewDocument((utc) => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
const offset = Date.parse(utc) - Date.now();
const RealDate = Date;
class ShiftedDate extends RealDate {
constructor(...args) {
if (args.length === 0) super(RealDate.now() + offset);
else super(...args);
}
static now() {
return RealDate.now() + offset;
}
}
globalThis.Date = ShiftedDate;
}, MIDDAY_UTC);
await page.setViewport({ width: 1280, height: 800 });
await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), {
waitUntil: 'domcontentloaded',
});
await waitForEngine(page, 'navara');
await freeze(page);
const setup = await page.evaluate(() => {
const a = window.__godsEyeView;
return {
cover: a.engine.render.cloudCover?.() ?? null,
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
canvases: document.querySelectorAll('canvas').length,
};
});
check(
'Navara takes the cockpit cloud cover itself, no cloud canvas',
setup.cover?.coverage === null && !setup.canvas,
setup,
);
const lookOwn = await converged(page, 'look-own');
await weatherToggle(page, true);
await cockpit(page, true);
await page.waitForFunction(
() => window.__godsEyeView.engine.render.cloudCover().coverage === 0,
{ timeout: 10000, polling: 100 },
);
check('clear weather in the cockpit: no cloud cover', asked.length >= 1, {
requests: asked.length,
});
const clear = await converged(page, 'cockpit-clear');
// Overcast 30 km east: the movement makes the weather stale.
weather = WEATHER.overcast;
await page.evaluate(() => {
const { engine } = window.__godsEyeView;
const place = engine.camera.pose().cartographic;
const east = place.longitude + 30000 / (6378137 * Math.cos(place.latitude));
const { sin, cos } = Math;
// WGS84 Earth-fixed position of (east, latitude, height).
const a = 6378137;
const e2 = 0.00669437999014;
const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2);
const h = place.height;
engine.camera.setView({
destination: {
x: (n + h) * cos(place.latitude) * cos(east),
y: (n + h) * cos(place.latitude) * sin(east),
z: (n * (1 - e2) + h) * sin(place.latitude),
},
orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 },
});
});
await page.waitForFunction(
() => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4,
{ timeout: 15000, polling: 100 },
);
const overcastCover = await coverage(page);
const overcast = await converged(page, 'cockpit-overcast');
const greyed = {
lookOwn: saturation(lookOwn),
clear: saturation(clear),
overcast: saturation(overcast),
clearToOvercast: diff(clear, overcast).mean,
};
check(
'overcast weather covers the view with Navara clouds, clear leaves it open',
overcastCover > 0.4 &&
greyed.overcast < greyed.clear * 0.6 &&
greyed.lookOwn < greyed.clear &&
greyed.clearToOvercast > 15,
{ overcastCover, greyed, requests: asked.length },
);
const contexts = await page.evaluate(() => ({
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
canvases: document.querySelectorAll('canvas').length,
}));
check(
'no second canvas or WebGL context in the cockpit',
!contexts.canvas && contexts.canvases === setup.canvases,
{ before: setup.canvases, after: contexts },
);
await cockpit(page, false);
check(
'leaving the cockpit gives the look its own clouds back',
(await coverage(page)) === null,
);
// Cesium keeps its own cockpit cloud canvas; its engine draws none.
await strict.switchTo(page, 'cesium');
await freeze(page);
weather = WEATHER.overcast;
await cockpit(page, true);
await page.waitForFunction(
() =>
Number(
document.getElementById('cockpit-cloud-effects')?.dataset.cloudStrength,
) > 0.3,
{ timeout: 15000, polling: 100 },
);
const cesium = await page.evaluate(() => {
const canvas = document.getElementById('cockpit-cloud-effects');
return {
setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover,
strength: Number(canvas.dataset.cloudStrength),
cockpit: canvas.dataset.cockpit,
};
});
check(
'Cesium: the cockpit draws its own cloud canvas, the engine none',
cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true',
cesium,
);
await cockpit(page, false);
await weatherToggle(page, false);
check('no page errors', errors.length === 0, errors);
if (outDir)
fs.writeFileSync(
path.join(outDir, 'cockpit-clouds.json'),
JSON.stringify(report, null, 2),
);
});
-301
View File
@@ -1,301 +0,0 @@
#!/usr/bin/env node
// UTILITIES ▸ Draw on Navara under the strict network policy, with real
// clicks and keys: Draw takes the pointer, clicks on the globe are vertices
// with a live rubber band, a double click finishes an area that paints on
// the canvas and joins the session board, Enter finishes a line, Escape
// cancels a sketch and then leaves Draw, and a switch to Cesium in the
// middle of a sketch ends it (the pointer is free, shape and colour kept);
// the shapes drawn on either engine stay on the board and draw on both.
//
// node scripts/qa-navara-draw.mjs http://127.0.0.1:<PORT>/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import sharp from 'sharp';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const VIEW = { lat: 37.7749, lon: -122.4194, alt: 6000 };
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0';
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
const strict = createStrict({ origin: base.origin, layers: ['draw'] });
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw';
fs.mkdirSync(SHOTS, { recursive: true });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// An area's corners, clear of the open UTILITIES panel and the rails.
const AREA = [
[560, 330],
[760, 330],
[760, 500],
[560, 500],
];
await strict.run(
{
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
},
async (browser) => {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 860 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base.href);
const ready = async (kind) => {
await page.waitForFunction(
(k) =>
window.__godsEyeView?.engine?.kind === k &&
window.__gevDrawTool?.attached &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 },
kind,
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await ready('navara');
assert.equal(
await page.evaluate(() =>
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
),
false,
'keyless preview required',
);
const tool = () =>
page.evaluate(() => {
const draw = window.__gevDrawTool;
return {
active: draw.active,
shape: draw.shape,
color: draw.color,
vertices: draw.session?.vertices.length ?? null,
board: window.__gevAnnotations.count(),
attached: window.__gevAnnotations.attached,
diagnostics: draw.diagnostics(),
band:
window.__godsEyeView.engine.lines
?.list?.()
.find(({ id }) => id === 'gev-draw-preview') ?? null,
};
});
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
const amber = async () => {
await page.addStyleTag({
content: '.gev-screen-whiteboard{visibility:hidden!important}',
});
await wait(800);
const { data, info } = await sharp(
await page.screenshot({
clip: { x: 520, y: 300, width: 280, height: 230 },
}),
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
await page.evaluate(() =>
document.querySelectorAll('style').forEach((style) => {
if (style.textContent.includes('gev-screen-whiteboard{visibility'))
style.remove();
}),
);
let n = 0;
for (let i = 0; i < data.length; i += info.channels) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
n++;
}
return n;
};
const openUtilities = () =>
page.evaluate(() => {
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
const drawShape = async (shape, points, finish) => {
await page.evaluate((s) => {
window.__gevDrawTool.setShape(s);
window.__gevDrawTool.setColor('amber');
}, shape);
for (const [x, y] of points) {
await page.mouse.click(x, y);
await wait(120);
}
await finish();
await wait(800);
};
await openUtilities();
await page.waitForSelector('#draw-toggle', { visible: true });
const bare = await amber();
await page.click('#draw-toggle');
await page.waitForFunction(() => window.__gevDrawTool.active);
const on = await page.evaluate(() => ({
owner: document.body.classList.contains('gev-drawing'),
}));
// Three corners, then the rubber band to the fourth under the pointer.
await page.evaluate(() => {
window.__gevDrawTool.setShape('area');
window.__gevDrawTool.setColor('amber');
});
// Each click is a vertex (the rubber band under the pointer never
// swallows one).
for (const [index, [x, y]] of AREA.slice(0, 3).entries()) {
await page.mouse.click(x, y);
await page.waitForFunction(
(n) => window.__gevDrawTool.session?.vertices.length === n,
{ timeout: 3000 },
index + 1,
);
await wait(150);
}
await page.mouse.move(...AREA[3]);
await wait(300);
const sketch = await tool();
await page.screenshot({ path: `${SHOTS}/navara-sketch.png` });
await strict.check(
page,
'draw',
'Draw takes the pointer; clicks on the globe are vertices with a live rubber band',
on.owner &&
sketch.active &&
sketch.vertices === 3 &&
sketch.diagnostics.sceneHandler &&
sketch.band?.shown === true,
sketch,
);
await page.mouse.click(AREA[3][0], AREA[3][1], { count: 2 });
await page.waitForFunction(() => window.__gevAnnotations.count() === 1, {
timeout: 10000,
});
await wait(800);
const drawn = await amber();
await page.screenshot({ path: `${SHOTS}/navara-area.png` });
const area = await page.evaluate(() =>
window.__gevAnnotations.list().map((m) => [m.type, m.color, m.manual]),
);
await strict.check(
page,
'draw',
'a double click finishes the area: it joins the board and paints amber on Navara',
area.length === 1 && area[0][0] === 'area' && drawn - bare > 1500,
{ area, bare, drawn },
);
// Enter finishes a line.
await drawShape(
'line',
[
[600, 560],
[760, 600],
],
() => page.keyboard.press('Enter'),
);
const lines = await page.evaluate(() =>
window.__gevAnnotations.list().map((m) => m.type),
);
await strict.check(
page,
'draw',
'Enter on the map finishes a line',
lines.length === 2 && lines.includes('route'),
{ lines },
);
// Escape: the sketch, then Draw itself.
await page.evaluate(() => window.__gevDrawTool.setShape('line'));
await page.mouse.click(620, 360);
await page.mouse.click(700, 380);
const before = await tool();
await page.keyboard.press('Escape');
const cancelled = await tool();
await page.keyboard.press('Escape');
const left = await tool();
await strict.check(
page,
'draw',
'Escape cancels the sketch, then leaves Draw and gives the pointer back',
before.vertices === 2 &&
cancelled.active &&
cancelled.vertices === 0 &&
!left.active &&
!left.diagnostics.sceneHandler &&
left.diagnostics.pointerOwner == null &&
left.band?.shown !== true,
{ before, cancelled, left },
);
assert.equal(left.board, 2, 'Escape keeps the finished shapes');
// A switch to Cesium in the middle of a sketch.
await page.click('#draw-toggle');
await page.waitForFunction(() => window.__gevDrawTool.active);
await page.evaluate(() => {
window.__gevDrawTool.setShape('line');
window.__gevDrawTool.setColor('green');
});
await page.mouse.click(620, 360);
await page.mouse.click(700, 380);
assert.equal((await tool()).vertices, 2);
await strict.pick(page, 'cesium');
await ready('cesium');
await wait(1500);
const switched = await tool();
await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` });
await strict.check(
page,
'draw',
'a switch mid-sketch ends the sketch, frees the pointer and keeps shape, colour and the board',
!switched.active &&
switched.vertices === null &&
switched.diagnostics.pointerOwner == null &&
switched.shape === 'line' &&
switched.color === 'green' &&
switched.board === 2 &&
switched.attached,
switched,
);
// Draw on Cesium too, with the same clicks.
await openUtilities();
await page.click('#draw-toggle');
await page.waitForFunction(() => window.__gevDrawTool.active);
await drawShape(
'line',
[
[600, 420],
[760, 450],
],
() => page.keyboard.press('Enter'),
);
await page.keyboard.press('Escape');
const onCesium = await tool();
await strict.check(
page,
'draw',
'a shape drawn on Cesium joins the same board',
onCesium.board === 3 && !onCesium.active,
onCesium,
);
await strict.pick(page, 'navara');
await ready('navara');
await wait(2000);
const back = await tool();
const backAmber = await amber();
await page.screenshot({ path: `${SHOTS}/navara-back.png` });
await strict.check(
page,
'draw',
'back on Navara the three shapes are on the board and the area paints again',
back.board === 3 && back.attached && backAmber - bare > 1500,
{ board: back.board, bare, backAmber },
);
await page.evaluate(() => window.__gevAnnotations.clear());
assert.deepEqual(errors, []);
console.log('PASS Draw on Navara with real clicks and keys');
},
);
-337
View File
@@ -1,337 +0,0 @@
#!/usr/bin/env node
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real
// file input, each one row; Navara draws their shapes in the row colour and
// their pins, counts what it cannot draw (the KMZ ground overlay) and says
// so in the hint; hide, fly-to and remove work on the engine; the files
// survive a switch to Cesium and back and draw on each engine. No file makes
// the page request anything it names (the strict policy fails any request
// outside the preview and the free tile cache).
//
// node scripts/qa-navara-geo-import.mjs http://127.0.0.1:<PORT>/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
import { fromDegrees } from '../src/geo/cartesian3.js';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
const VIEW = { lat: 37.7694, lon: -122.4862, alt: 4200 };
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0';
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
const strict = createStrict({ origin: base.origin, layers: ['geo-import'] });
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import';
fs.mkdirSync(SHOTS, { recursive: true });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TRAP = 'gev-qa-trap.invalid';
/* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */
const ring = [
[-122.511, 37.7645],
[-122.4545, 37.7645],
[-122.4545, 37.7745],
[-122.511, 37.7745],
[-122.511, 37.7645],
];
const geojson = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
name: 'QA park',
description: `<img src="https://${TRAP}/props.png">`,
},
geometry: { type: 'Polygon', coordinates: [ring] },
},
{
type: 'Feature',
properties: { name: 'QA pin' },
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] },
},
],
});
const kml = (iconHref, overlay) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description><![CDATA[<img src="https://${TRAP}/balloon.png">]]></description>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA KML line</name>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>${overlay}</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
</Document>
</kml>`;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
<trk><name>QA track</name><trkseg>
<trkpt lat="37.766" lon="-122.505"></trkpt><trkpt lat="37.768" lon="-122.49"></trkpt>
</trkseg></trk>
</gpx>`;
// 8×8 opaque white PNG.
const PNG = await sharp({
create: { width: 8, height: 8, channels: 4, background: '#ffffff' },
})
.png()
.toBuffer();
function storedZip(files) {
const parts = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const n = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(n.length, 26);
const c = Buffer.alloc(46);
c.writeUInt32LE(0x02014b50, 0);
c.writeUInt32LE(data.length, 20);
c.writeUInt32LE(data.length, 24);
c.writeUInt16LE(n.length, 28);
c.writeUInt32LE(offset, 42);
parts.push(local, n, data);
central.push(c, n);
offset += 30 + n.length + data.length;
}
const dir = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(dir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, dir, end]);
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-navara-import-'));
const fixture = (name, data) => {
const file = path.join(dir, name);
fs.writeFileSync(file, data);
return file;
};
const FILES = [
fixture('park.geojson', geojson),
fixture(
'remote-bits.kml',
kml(`https://${TRAP}/icon.png`, `https://${TRAP}/overlay.png`),
),
fixture(
'packed.kmz',
storedZip([
['doc.kml', Buffer.from(kml('files/pin.png', 'files/overlay.png'))],
['files/pin.png', PNG],
['files/overlay.png', PNG],
]),
),
fixture('walk.gpx', gpx),
];
await strict.run(
{
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
},
async (browser) => {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 860 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base.href);
const ready = async (kind) => {
await page.waitForFunction(
(k) =>
window.__godsEyeView?.engine?.kind === k &&
window.__gevGeoImport?.diagnostics().attached &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 },
kind,
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
};
await ready('navara');
assert.equal(
await page.evaluate(() =>
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
),
false,
'keyless preview required',
);
const state = () =>
page.evaluate(() => ({
files: window.__gevGeoImport.list(),
diagnostics: window.__gevGeoImport.diagnostics(),
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
.length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
// Movers are a Navara capability; Cesium draws the pins as entities.
pins: (window.__godsEyeView.engine.movers?.list() ?? [])
.filter(({ id }) => id.startsWith('geo-import-'))
.reduce((n, set) => n + set.count, 0),
}));
/** Pixels near the first file's colour (#39d0ff) on the canvas. */
const cyan = async () => {
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
await wait(900);
const { data, info } = await sharp(
await page.screenshot({
clip: { x: 400, y: 260, width: 480, height: 300 },
}),
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let n = 0;
for (let i = 0; i < data.length; i += info.channels) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (b > 120 && g > 90 && b - r > 50) n++;
}
return n;
};
const setAll = (visible) =>
page.evaluate((v) => {
for (const file of window.__gevGeoImport.list())
window.__gevGeoImport.setVisible(file.id, v);
}, visible);
await page.evaluate(() => {
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
const input = await page.$('#geo-import-input');
assert.ok(input, 'the Import file input');
await input.uploadFile(...FILES);
await page.waitForFunction(
() =>
window.__gevGeoImport.list().length === 4 &&
!/Reading/.test(
document.getElementById('geo-import-hint')?.textContent || '',
),
{ timeout: 90000 },
);
const imported = await state();
console.log('navara import', JSON.stringify(imported));
await strict.check(
page,
'geo-import',
'GeoJSON, KML, KMZ and GPX each become one row and one drawing on Navara',
imported.files.length === 4 &&
imported.rows === 4 &&
imported.diagnostics.drawn === 4,
{ files: imported.files.map((f) => f.format), rows: imported.rows },
);
// The KMZ's packed ground overlay is a rectangle Navara has no drawing
// for; the KML's remote one was left out of the file on import.
const undrawn = Object.fromEntries(
imported.files.map((f) => [f.name, f.undrawn]),
);
await strict.check(
page,
'geo-import',
'the ground overlay Navara cannot draw is counted and reported in the hint',
undrawn['packed.kmz'] === 1 &&
undrawn['park.geojson'] === 0 &&
undrawn['remote-bits.kml'] === 0 &&
undrawn['walk.gpx'] === 0 &&
/leaves out of packed\.kmz: 1/.test(imported.hint),
{ undrawn, hint: imported.hint },
);
await strict.check(
page,
'geo-import',
'the files’ pins are drawn as Navara sprites',
imported.pins >= 4,
{ pins: imported.pins },
);
// The first file is flown to after the batch; settle there.
await wait(3000);
await page.screenshot({ path: `${SHOTS}/navara-imported.png` });
for (const kind of ['navara', 'cesium', 'navara']) {
if (
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
) {
await strict.pick(page, kind);
await ready(kind);
await page.waitForFunction(
() => window.__gevGeoImport.diagnostics().drawn === 4,
{ timeout: 60000 },
);
await wait(2500);
await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` });
}
const shown = await cyan();
await setAll(false);
const hidden = await cyan();
await setAll(true);
console.log(kind, 'cyan', { shown, hidden });
await strict.check(
page,
'geo-import',
'the imported shapes paint the first file’s colour and stop when hidden',
shown - hidden > 1500,
{ shown, hidden },
);
const kept = await state();
await strict.check(
page,
'geo-import',
'the same four files are listed and drawn on this engine',
kept.files.length === 4 && kept.diagnostics.drawn === 4,
kept.diagnostics,
);
}
// Fly-to on Navara goes through the engine camera.
const flight = await page.evaluate(
async (destination) => {
const { engine } = window.__godsEyeView;
engine.camera.setView({ destination });
const before = engine.camera.height();
const [first] = window.__gevGeoImport.list();
const ok = window.__gevGeoImport.flyTo(first.id);
await new Promise((r) => setTimeout(r, 2500));
return { ok, before, after: engine.camera.height() };
},
fromDegrees(-100, 40, 3e6),
);
await strict.check(
page,
'geo-import',
'Fly to a file moves the Navara camera onto it',
flight.ok && flight.after < flight.before / 2,
flight,
);
// Remove gives the drawing back.
await page.evaluate(() => {
for (const file of window.__gevGeoImport.list())
window.__gevGeoImport.remove(file.id);
});
const removed = await state();
await strict.check(
page,
'geo-import',
'Remove takes every file and its pins off Navara',
removed.files.length === 0 && removed.rows === 0 && removed.pins === 0,
{ files: removed.files.length, pins: removed.pins },
);
assert.deepEqual(errors, []);
console.log('PASS Navara import of GeoJSON, KML, KMZ and GPX');
},
);
-180
View File
@@ -1,180 +0,0 @@
#!/usr/bin/env node
// The DISPLAY scope mask and the celestial ring on Navara, under the strict
// network policy: at city altitude the mask canvas sits in the engine's
// container with an opaque outside, the screen's edge goes dark with the
// scope on and shows the map with it off, and the ring overlay draws; the
// same holds after a switch to Cesium and back.
//
// node scripts/qa-navara-scope.mjs http://127.0.0.1:<PORT>/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import sharp from 'sharp';
import { fromDegrees } from '../src/geo/cartesian3.js';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0';
base.hash =
'v=2&lat=37.7749&lon=-122.4194&alt=3000&heading=0&pitch=-90&roll=0&map=osm&l=';
const strict = createStrict({
origin: base.origin,
layers: ['scope-mask', 'celestial-ring'],
});
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-scope';
fs.mkdirSync(SHOTS, { recursive: true });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// The screen's right edge below the rail: outside the keyhole, clear of UI.
const GLOBE = fromDegrees(-122.4194, 37.7749, 20_000_000);
const CITY = fromDegrees(-122.4194, 37.7749, 3000);
const EDGE = { x: 1250, y: 560, width: 26, height: 90 };
await strict.run(
{
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
},
async (browser) => {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 860 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base.href);
await waitForEngine(page, 'navara');
assert.equal(
await page.evaluate(() =>
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
),
false,
'keyless preview required',
);
const edgeLuminance = async () => {
await wait(900);
const { data, info } = await sharp(await page.screenshot({ clip: EDGE }))
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let sum = 0;
for (let i = 0; i < data.length; i += info.channels)
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
return sum / (data.length / info.channels);
};
// DISPLAY ▸ Scope, as its checkbox sends it.
const setScope = async (enabled) => {
const found = await page.evaluate((on) => {
const toggle = document.getElementById('scope-toggle');
if (!toggle) return false;
toggle.checked = on;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}, enabled);
assert.ok(found, 'the DISPLAY scope toggle');
};
for (const [round, kind] of ['navara', 'cesium', 'navara'].entries()) {
if (
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
) {
await strict.switchTo(page, kind);
}
await wait(2500);
const mask = await page.evaluate(() => {
const canvas = document.getElementById('scope-mask');
const { engine } = window.__godsEyeView;
const context = canvas?.getContext('2d');
const corner = context?.getImageData(2, 2, 1, 1).data;
const middle = context?.getImageData(
Math.round(canvas.width / 2),
Math.round(canvas.height / 2),
1,
1,
).data;
return {
exists: Boolean(canvas),
inContainer: canvas?.parentElement === engine.host.container,
count: document.querySelectorAll('#scope-mask').length,
height: Math.round(engine.camera.height()),
cornerAlpha: corner?.[3],
middleAlpha: middle?.[3],
};
});
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-scope.png` });
const on = await edgeLuminance();
await setScope(false);
const off = await edgeLuminance();
const cleared = await page.evaluate(
() =>
document
.getElementById('scope-mask')
?.getContext('2d')
.getImageData(2, 2, 1, 1).data[3],
);
await setScope(true);
console.log(kind, JSON.stringify({ mask, on, off, cleared }));
await strict.check(
page,
'scope-mask',
'one scope canvas in the engine container, opaque outside and clear inside at city altitude',
mask.exists &&
mask.inContainer &&
mask.count === 1 &&
mask.cornerAlpha === 255 &&
mask.middleAlpha === 0,
mask,
);
await strict.check(
page,
'scope-mask',
'the screen edge is dark with the scope on and shows the map with it off',
on < 12 && off > on + 30 && cleared === 0,
{ on, off, cleared },
);
// The ring is a full-globe view: up to the whole Earth, ring on.
await page.evaluate((destination) => {
const { engine, styleManager } = window.__godsEyeView;
engine.camera.setView({ destination });
styleManager.setCelestialRingEnabled(true);
}, GLOBE);
await wait(2500);
const ring = await page.evaluate(() => {
const overlay = document.getElementById('celestial-ring-overlay');
const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])];
return {
exists: Boolean(overlay),
disabled: overlay?.classList.contains('disabled') ?? null,
visible: overlay?.classList.contains('visible') ?? null,
updates: Number(overlay?.dataset.ephemerisUpdates ?? 0),
canvases: canvases.map((c) => [c.width, c.height]),
};
});
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-ring.png` });
console.log(kind, 'ring', JSON.stringify(ring));
await strict.check(
page,
'celestial-ring',
'at full-globe altitude the celestial ring draws its sun and moon',
ring.exists &&
ring.disabled === false &&
ring.visible === true &&
ring.updates >= 1 &&
ring.canvases.length === 2 &&
ring.canvases.every(([w, h]) => w > 0 && h > 0),
ring,
);
// Back down to the city for the next engine.
await page.evaluate((destination) => {
const { engine, styleManager } = window.__godsEyeView;
styleManager.setCelestialRingEnabled(false);
engine.camera.setView({ destination });
}, CITY);
await wait(1000);
}
assert.deepEqual(errors, []);
console.log('PASS scope mask and celestial ring on both engines');
},
);
-286
View File
@@ -1,286 +0,0 @@
// The Cyber HUD sonar on Navara: with the Cyber HUD on, the
// sweep lights the contacts it passes and dims the rest to the opacity
// floor, by each contact's place on screen exactly as the CPU sampler of
// src/cyberSonar.js rates it; the sweep turns; with the sweep off every
// contact takes the neutral factor; a parked scene draws at the fleet
// cadence only while the sweep runs over contacts; leaving the Cyber HUD
// gives the contacts back unchanged. On Cesium the command adapter keeps the
// treatment and the engine draws none. The contacts are an engine point set
// (engine.movers) in a ring around the screen centre. Keyless preview under
// the strict default-deny network policy.
//
// node scripts/qa-navara-sonar.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import { checker, freeze, visualPageUrl } from './qa/navaraVisual.mjs';
import { sonarFactorAt } from '../src/globe/navara/sonar.js';
const base = localPreview(process.argv[2]);
const outDir = process.argv
.find((arg) => arg.startsWith('--out='))
?.slice('--out='.length);
const strict = createStrict({ origin: base.origin, layers: [] });
const report = { checks: [] };
const check = checker(report, assert);
const WIDTH = 1280;
const HEIGHT = 800;
// 32 contacts on a ring at half the keyhole radius (11.25° apart: one is
// always well inside the 24° sector), and one outside the keyhole.
function contactPlaces() {
const radius = Math.min(WIDTH * 0.4, HEIGHT * 0.52) * 0.5;
const places = [];
for (let i = 0; i < 32; i++) {
const angle = (i / 32) * Math.PI * 2;
places.push({
x: Math.round(WIDTH / 2 + Math.sin(angle) * radius),
y: Math.round(HEIGHT / 2 - Math.cos(angle) * radius),
});
}
places.push({ x: 120, y: 110 });
return places;
}
// The luma at each contact (a 3×3 mean) of the next drawn frame, and the
// sonar state that frame drew with.
const read = (page, places) =>
page.evaluate(async (places) => {
const { engine } = window.__godsEyeView;
const canvas = document.createElement('canvas');
const source = engine.host.canvas;
canvas.width = source.width;
canvas.height = source.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let sonar = null;
await new Promise((resolve) => {
const off = engine.frames.postRender.addEventListener(() => {
off();
ctx.drawImage(source, 0, 0);
sonar = engine.render.contactSonar?.() ?? null;
resolve();
});
engine.render.request('qa-sonar');
});
const scale = source.width / engine.host.size().width;
const luma = places.map(({ x, y }) => {
const data = ctx.getImageData(
Math.round(x * scale) - 1,
Math.round(y * scale) - 1,
3,
3,
).data;
let sum = 0;
for (let i = 0; i < data.length; i += 4)
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
return sum / 9;
});
return { luma, sonar };
}, places);
// Frames the engine draws in `ms` without being asked.
const framesIn = (page, ms) =>
page.evaluate(async (ms) => {
const { engine } = window.__godsEyeView;
let frames = 0;
const off = engine.frames.postRender.addEventListener(() => frames++);
await new Promise((resolve) => setTimeout(resolve, ms));
off();
return frames;
}, ms);
const mean = (values) =>
values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
await strict.run({}, async (browser) => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.evaluateOnNewDocument(() => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
});
await page.setViewport({ width: WIDTH, height: HEIGHT });
await page.goto(visualPageUrl(base.origin, 'navara'), {
waitUntil: 'domcontentloaded',
});
await waitForEngine(page, 'navara');
await freeze(page);
const places = contactPlaces();
const placed = await page.evaluate((places) => {
const { engine } = window.__godsEyeView;
const set = engine.movers.createSet({
id: 'qa-sonar-contacts',
kind: 'point',
capacity: places.length,
depthTest: 'always',
point: { sizePx: 14, outlinePx: 0 },
});
window.__qaSonarSet = set;
let missing = 0;
places.forEach((place, index) => {
const position = engine.camera.pickEllipsoid(place);
if (!position) missing++;
set.set(index, { position, color: [1, 1, 1, 1] });
});
set.setCount(places.length);
set.commit();
return { missing, sonar: engine.render.contactSonar?.() ?? null };
}, places);
check(
'Navara treats contacts itself; the sonar starts off',
placed.missing === 0 && placed.sonar?.enabled === false,
placed,
);
const baseline = await read(page, places);
await page.evaluate(() => {
const { styleManager } = window.__godsEyeView;
styleManager._setHudVariant('cyber');
// Normal keeps the contacts' own colours; the HUD stays on (in Normal
// it would hide itself, and the sweep with it).
styleManager.setStyle('normal');
styleManager.hud.setMode('on');
styleManager._setCyberSonarEnabled(true);
styleManager._setCyberSonarSetting('opacity', 35);
});
await page.waitForFunction(
() => window.__godsEyeView.styleManager.transitions.size === 0,
);
const theme = await page.evaluate(() => ({
theme: document.documentElement.dataset.uiTheme,
gpu: document.documentElement.dataset.cyberSonarGpu,
hud: document.getElementById('intel-hud')?.classList.contains('active'),
}));
check(
'the Cyber HUD runs with the contact adapter',
theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud,
theme,
);
const sweeps = [];
for (const wait of [0, 1300, 1300]) {
await new Promise((resolve) => setTimeout(resolve, wait));
const { luma, sonar } = await read(page, places);
const expected = places.map(({ x, y }) =>
sonarFactorAt(x, y, WIDTH, HEIGHT, sonar),
);
const lit = luma.filter((_, i) => expected[i] > 0.9);
const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100);
sweeps.push({
spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)),
angle: sonar.angleDeg,
lit: lit.length,
litLuma: mean(lit),
dimLuma: mean(dim),
brightest: luma.indexOf(Math.max(...luma)),
expectedBrightest: expected.indexOf(Math.max(...expected)),
sonar,
});
check(
`the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`,
sonar.enabled &&
sonar.active &&
sonar.opacity === 35 &&
lit.length >= 1 &&
dim.length >= 8 &&
mean(lit) > mean(dim) + 20 &&
expected[places.length - 1] === 0.35,
sweeps.at(-1),
);
}
check(
'the sweep turns: another contact is lit',
new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 &&
new Set(sweeps.map((s) => s.brightest)).size > 1,
sweeps.map(({ angle, brightest, expectedBrightest }) => ({
angle,
brightest,
expectedBrightest,
})),
);
const cadence = await framesIn(page, 1500);
check(
'a parked scene draws at the fleet cadence while the sweep runs over contacts',
cadence >= 8,
{ frames: cadence },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager._setCyberSonarEnabled(false),
);
const neutral = await read(page, places);
const spread =
Math.max(...neutral.luma.slice(0, 32)) -
Math.min(...neutral.luma.slice(0, 32));
await new Promise((resolve) => setTimeout(resolve, 300));
const idle = await framesIn(page, 1500);
check(
'with the sweep off every contact takes the neutral factor and the scene rests',
neutral.sonar.enabled &&
!neutral.sonar.active &&
// What still differs is the map under the translucent contacts.
spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 &&
idle <= 3,
{ spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle },
);
await page.evaluate(() => {
const { styleManager } = window.__godsEyeView;
styleManager._setCyberSonarEnabled(true);
styleManager._setHudVariant('tactical');
styleManager.setStyle('normal');
styleManager.hud.setMode('auto');
});
await page.waitForFunction(
() => window.__godsEyeView.styleManager.transitions.size === 0,
);
const after = await read(page, places);
const drift = Math.max(
...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])),
);
check(
'leaving the Cyber HUD gives the contacts back unchanged',
after.sonar.enabled === false && drift < 3,
{ drift },
);
await page.evaluate(() => window.__qaSonarSet.destroy());
// Cesium keeps its command adapter; its engine draws no sonar.
await strict.switchTo(page, 'cesium');
await page.evaluate(() => {
const { styleManager } = window.__godsEyeView;
styleManager._setHudVariant('cyber');
});
await page.waitForFunction(
() => document.documentElement.dataset.cyberSonarGpu,
{ timeout: 10000 },
);
const cesium = await page.evaluate(() => ({
setContactSonar: typeof window.__godsEyeView.engine.render.setContactSonar,
gpu: document.documentElement.dataset.cyberSonarGpu,
}));
check(
'Cesium: the command adapter treats contacts, the engine none',
cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported',
cesium,
);
await page.evaluate(() =>
window.__godsEyeView.styleManager._setHudVariant('tactical'),
);
check('no page errors', errors.length === 0, errors);
if (outDir) {
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(
path.join(outDir, 'sonar.json'),
JSON.stringify(report, null, 2),
);
}
});
-318
View File
@@ -1,318 +0,0 @@
// The app's visual styles on Navara: each of CRT, NVG, FLIR,
// Anime and Noir filters the Navara frame (pixels and a style-specific
// signature against Normal), switching styles crossfades (both styles drawn
// at once mid-way, the fade completes), Normal restores the frame, the
// retired Snow opens on Normal, a link's style opens on Navara, and on
// Cesium the styles still run as Cesium post stages with the engine left
// alone. Keyless preview under the strict default-deny network policy.
//
// node scripts/qa-navara-visual-styles.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
STILL_VIEW,
capture,
checker,
diff,
freeze,
histogramShift,
pickStyle,
pinDaytime,
saveCapture,
stats,
visualPageUrl,
} from './qa/navaraVisual.mjs';
const base = localPreview(process.argv[2]);
const outDir = process.argv
.find((arg) => arg.startsWith('--out='))
?.slice('--out='.length);
const STYLES = ['retro', 'surveillance', 'thermal', 'anime', 'noir'];
const URL_NAMES = { retro: 'crt', surveillance: 'nvg', thermal: 'flir' };
const pageUrl = (engine, extra = '') =>
visualPageUrl(base.origin, engine, extra, STILL_VIEW);
const strict = createStrict({ origin: base.origin, layers: [] });
const report = { checks: [] };
const check = checker(report, assert);
const save = (name, pixels) => saveCapture(outDir, name, pixels);
await strict.run({}, async (browser) => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
// The same sun on every run: the frame's brightness decides what blooms
// and what each style's signature reads.
await pinDaytime(page);
await page.evaluateOnNewDocument(() => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
});
await page.setViewport({ width: 1280, height: 800 });
await page.goto(pageUrl('navara'), { waitUntil: 'domcontentloaded' });
await waitForEngine(page, 'navara');
await freeze(page);
const capabilities = await page.evaluate(() => {
const { engine } = window.__godsEyeView;
return {
styles: engine.render.visualStyles,
pipeline: engine.pipeline(),
};
});
check(
'Navara draws every app style itself',
JSON.stringify(capabilities.styles) === JSON.stringify(STYLES),
capabilities,
);
check(
'no style pass shows on Normal',
capabilities.pipeline?.shown === false,
capabilities.pipeline,
);
// The military styles' presets set sharpening and detection (as on
// Cesium); Normal keeps them, so they are put back before comparing.
const display = await page.evaluate(
() => window.__godsEyeView.styleManager.getControlState().sharpen,
);
const normal = await capture(page);
const normalAgain = await capture(page);
const noise = diff(normal, normalAgain);
check('Normal is a still frame to compare against', noise.mean < 0.5, noise);
save('normal', normal);
const normalStats = stats(normal);
let previous = 'normal';
for (const name of STYLES) {
const fade = await pickStyle(page, name, previous);
check(
`${name}: the crossfade completes`,
fade.pending === 0 && fade.ms < 1500 && fade.active === name,
{ ms: fade.ms, pending: fade.pending },
);
const between = fade.samples.filter(
([, next]) => next > 0.02 && next < 0.98,
);
check(
`${name}: the engine fades it in over several frames`,
between.length >= 1,
{ frames: fade.samples.length, between: between.length },
);
if (previous !== 'normal') {
const both = fade.samples.filter(
([out, next]) => out > 0.05 && out < 0.95 && next > 0.05 && next < 0.95,
);
check(
`${name}: ${previous} and ${name} draw together mid-crossfade`,
both.length >= 1,
{ both: both.length },
);
}
const others = Object.entries(fade.intensities).filter(
([style, value]) => style !== name && value !== 0,
);
check(
`${name}: alone at full intensity once settled`,
fade.intensities[name] === 1 && others.length === 0,
fade.intensities,
);
const order = fade.pipeline?.passes ?? [];
const at = order.indexOf('gevStyles');
check(
`${name}: the style pass shows, last before the final copy and after the overlays`,
fade.pipeline?.shown === true &&
at >= 0 &&
order[at + 1] === 'final' &&
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
(key) => !order.includes(key) || order.indexOf(key) < at,
),
fade.pipeline,
);
const pixels = await capture(page);
save(name, pixels);
const styled = stats(pixels);
const change = diff(normal, pixels);
const shift = histogramShift(normalStats, styled);
check(
`${name}: the frame changes against Normal`,
change.mean > 6 && change.changedShare > 0.25,
{ ...change, histogramShift: shift },
);
const signature = {
// Night vision: phosphor green over red and blue.
surveillance: styled.g > styled.r * 1.3 && styled.g > styled.b * 1.3,
// FLIR white-hot (palette 0): grey.
thermal: styled.saturation < normalStats.saturation * 0.5,
// Noir: greyer, with a slight sepia, and vignetted corners.
noir:
styled.saturation < normalStats.saturation &&
styled.vignette < normalStats.vignette * 0.85,
// Anime: cel bands, more saturated.
anime: styled.saturation > normalStats.saturation * 1.05 || shift > 0.05,
// CRT: pixelated, dithered, scanlined.
retro: shift > 0.05 || change.mean > 12,
}[name];
check(`${name}: the style's own look`, signature, {
normal: {
r: normalStats.r,
g: normalStats.g,
b: normalStats.b,
saturation: normalStats.saturation,
vignette: normalStats.vignette,
},
styled: {
r: styled.r,
g: styled.g,
b: styled.b,
saturation: styled.saturation,
vignette: styled.vignette,
},
histogramShift: shift,
});
previous = name;
}
// A style parameter reaches the Navara pass (FLIR's Ironbow palette).
await pickStyle(page, 'thermal', previous);
previous = 'thermal';
const grey = await capture(page);
const ironbow = await page.evaluate(() => {
const a = window.__godsEyeView;
a.styleManager.stages.thermal.uniforms.palette = 1;
return a.engine.render.style('thermal').uniforms.palette;
});
const coloured = await capture(page);
check(
'FLIR Ironbow reaches the engine and colours the frame',
ironbow === 1 && stats(coloured).saturation > stats(grey).saturation + 10,
{
grey: stats(grey).saturation,
ironbow: stats(coloured).saturation,
},
);
await page.evaluate(() => {
window.__godsEyeView.styleManager.stages.thermal.uniforms.palette = 0;
});
const back = await pickStyle(page, 'normal', previous);
check(
'Normal: every style back at 0 and the pass hidden',
Object.values(back.intensities).every((v) => v === 0) &&
back.pipeline?.shown === false,
back,
);
await page.evaluate((sharpen) => {
const { styleManager } = window.__godsEyeView;
styleManager._setDetectionMode?.('OFF');
styleManager.setSharpen(sharpen);
}, display);
const restored = await capture(page);
save('normal-restored', restored);
const residue = diff(normal, restored);
check(
'Normal restores the frame',
residue.mean < 1 && residue.changedShare < 0.01,
residue,
);
// The retired Snow opens on Normal; a link's style opens on Navara.
await page.goto(pageUrl('navara', '&style=snow'), {
waitUntil: 'domcontentloaded',
});
await waitForEngine(page, 'navara');
const snow = await page.evaluate(() => {
const a = window.__godsEyeView;
return {
active: a.styleManager.activeStyle,
intensities: a.engine.render.visualStyles.map(
(s) => a.engine.render.style(s).intensity,
),
};
});
check(
'style=snow opens on Normal',
snow.active === 'normal' && snow.intensities.every((v) => v === 0),
snow,
);
await page.goto(pageUrl('navara', `&style=${URL_NAMES.thermal}`), {
waitUntil: 'domcontentloaded',
});
await waitForEngine(page, 'navara');
await page.waitForFunction(
() => window.__godsEyeView.engine.render.style('thermal').intensity === 1,
{ timeout: 10000, polling: 50 },
);
check('a FLIR link opens filtered on Navara', true);
// Cesium keeps its own post stages; its engine draws no styles. The
// switch is real input on the picker, in the same document.
await page.evaluate(() =>
window.__godsEyeView.styleManager.setStyle('normal'),
);
await page.waitForFunction(
() => window.__godsEyeView.styleManager.transitions.size === 0,
);
await strict.switchTo(page, 'cesium');
await freeze(page);
// The address may still carry the FLIR link: Normal first.
await pickStyle(page, 'normal', 'thermal');
await page.evaluate((sharpen) => {
const { styleManager } = window.__godsEyeView;
styleManager._setDetectionMode?.('OFF');
styleManager.setSharpen(sharpen);
}, display);
const cesiumNormal = await capture(page);
const cesium = await pickStyle(page, 'thermal', 'normal');
const cesiumState = await page.evaluate(() => {
const a = window.__godsEyeView;
const stage = a.styleManager.stages.thermal;
return {
setStyle: typeof a.engine.render.setStyle,
stageName: stage.name,
enabled: stage.enabled,
intensity: stage.uniforms.intensity,
inScene: a.viewer?.scene?.postProcessStages?.contains?.(stage) ?? null,
};
});
const cesiumThermal = await capture(page);
save('cesium-normal', cesiumNormal);
save('cesium-thermal', cesiumThermal);
if (outDir)
await page.screenshot({
path: path.join(outDir, 'cesium-thermal-page.png'),
});
check(
'Cesium: FLIR is its own post stage, the engine draws none',
cesium.pending === 0 &&
cesiumState.setStyle === 'undefined' &&
cesiumState.enabled === true &&
cesiumState.intensity === 1 &&
cesiumState.inScene !== false,
cesiumState,
);
const cesiumChange = diff(cesiumNormal, cesiumThermal);
check('Cesium: FLIR changes its frame', cesiumChange.mean > 6, cesiumChange);
await pickStyle(page, 'normal', 'thermal');
await page.evaluate((sharpen) => {
const { styleManager } = window.__godsEyeView;
styleManager._setDetectionMode?.('OFF');
styleManager.setSharpen(sharpen);
}, display);
const cesiumBack = diff(cesiumNormal, await capture(page));
check('Cesium: Normal restores its frame', cesiumBack.mean < 1.5, cesiumBack);
check('no page errors', errors.length === 0, errors);
if (outDir)
fs.writeFileSync(
path.join(outDir, 'visual-styles.json'),
JSON.stringify(report, null, 2),
);
});
-185
View File
@@ -1,185 +0,0 @@
import sharp from 'sharp';
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=28.2110&lon=85.3380&alt=9000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
// The media panel embeds YouTube players with their thumbnails: a plain
// thumbnail and an inert player page stand in for them.
const thumbnail = await sharp({
create: { width: 480, height: 360, channels: 3, background: '#40506a' },
})
.jpeg()
.toBuffer();
const strict = createStrict({
origin: base.origin,
layers: ['bhote-koshi-2026'],
routes: [
{
pattern: 'https://i.ytimg.com/*',
respond: () => ({ contentType: 'image/jpeg', body: thumbnail }),
},
{
// The player API script: an inert stub (the app keeps its own controls).
pattern: 'https://www.youtube.com/iframe_api*',
respond: () => ({
contentType: 'text/javascript',
body: '/* QA: YouTube iframe API withheld */',
}),
},
{
pattern: 'https://www.youtube-nocookie.com/*',
respond: () => ({
contentType: 'text/html',
body: '<!doctype html><title>QA player</title><body style="background:#111"></body>',
}),
},
// A Facebook post the playback reaches in a slower run: its SDK and
// post page, inert as well.
{
pattern: 'https://connect.facebook.net/*',
respond: () => ({
contentType: 'text/javascript',
body: '/* QA: Facebook SDK withheld */',
}),
},
{
pattern: 'https://www.facebook.com/*',
respond: () => ({
contentType: 'text/html',
body: '<!doctype html><title>QA post</title><body style="background:#111"></body>',
}),
},
],
});
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b, { shaderErrors: true });
const assets = [];
p.on('request', (req) => {
const path = new URL(req.url()).pathname;
if (
/\/events\/bhote-koshi-2026\/(event\.json|pre\.webp|post\.webp)$/.test(
path,
)
)
assets.push(path);
});
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { tileset, dataManager, sceneDirector } = window.__godsEyeView;
if (!sceneDirector?.listScenes().length)
throw Error('No scenes available on Navara');
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
const mod = dataManager.layers.get('bhote-koshi-2026').module;
window.qaEvent = mod;
await dataManager.setEnabled(mod.id, true, { origin: 'user' });
mod.setParams({
presentation: 'standalone',
progress: 0.52,
split: 0.27,
autoPlay: false,
});
});
const sample = async () => {
await new Promise((r) => setTimeout(r, 500));
return sharp(await p.screenshot())
.extract({ left: 490, top: 370, width: 25, height: 25 })
.removeAlpha()
.raw()
.toBuffer();
};
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await strict.switchTo(p, kind);
}
const state = await p.evaluate(() => {
const { dataManager } = window.__godsEyeView;
return {
same:
window.qaEvent === dataManager.layers.get('bhote-koshi-2026').module,
state: window.qaEvent.getPlaybackState(),
player:
document.querySelector('#bhote-koshi-embedded-media-root') ===
window.qaPlayer,
};
});
assert.equal(state.same, true);
assert.equal(state.state.enabled, true);
assert.equal(state.state.split, 0.27);
assert.equal(state.state.playing, false);
assert.equal(state.state.progress, 0.52);
await p.evaluate(() => {
window.qaPlayer ??= document.querySelector(
'#bhote-koshi-embedded-media-root',
);
window.qaEvent.setParams({ split: 0.75 });
});
assert.ok(
await p.evaluate(() => Boolean(window.qaPlayer)),
'persistent embedded media root',
);
const before = await sample();
await p.evaluate(() => window.qaEvent.setParams({ split: 0.27 }));
const after = await sample();
const diff =
before.reduce((n, v, i) => n + Math.abs(v - after[i]), 0) / before.length;
console.log(kind, state.state, 'imagery difference', diff);
assert.ok(diff > 6, 'divider changes the actual imagery in the viewport');
await strict.passed(
p,
'bhote-koshi-2026',
'same model and media root; paused at 0.52 with split 0.27; the divider changes the imagery',
{ diff },
);
assert.equal(
await p.evaluate(
() =>
document.querySelector('#bhote-koshi-embedded-media-root') ===
window.qaPlayer,
),
true,
);
}
for (const suffix of ['/event.json', '/pre.webp', '/post.webp'])
assert.equal(
assets.filter((a) => a.endsWith(suffix)).length,
1,
JSON.stringify(assets),
);
await strict.passed(
p,
'bhote-koshi-2026',
'event file and image pair read once over three engine visits',
);
await p.evaluate(() => window.qaEvent.setParams({ autoPlay: true }));
await p.waitForFunction(
() => window.qaEvent.getPlaybackState().progress > 0.53,
);
await strict.switchTo(p, 'cesium');
const live = await p.evaluate(() => window.qaEvent.getPlaybackState());
await strict.check(
p,
'bhote-koshi-2026',
'playback keeps playing past 0.53 through the switch',
live.playing && live.progress > 0.53,
live,
);
await p.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bhote-koshi-2026', false, {
origin: 'user',
}),
);
assert.deepEqual(errors, []);
console.log(
'PASS event timeline, paused and playing handoff, image pair, divider, player host and single asset acquisition',
);
});
-131
View File
@@ -1,131 +0,0 @@
import { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp';
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
base.search = '?engine=navara&welcome=0&look=cinematic';
base.hash =
'v=2&lat=28.3326&lon=85.4848&alt=18000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
const strict = createStrict({
origin: base.origin,
layers: ['bhote-koshi-locator'],
});
await strict.run({}, async (b) => {
const { page: p, errors } = await openPage(b, { shaderErrors: true });
await p.goto(base.href);
await waitForEngine(p, 'navara');
await p.evaluate(async () => {
const { tileset, dataManager } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Keyless preview required');
window.qaLocator = dataManager.layers.get('bhote-koshi-locator').module;
window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' });
await dataManager.setEnabled('bhote-koshi-locator', true, {
origin: 'user',
});
});
for (const kind of ['navara', 'cesium', 'navara']) {
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
await strict.switchTo(p, kind);
}
const { same, stats, params, at } = await p.evaluate(
(world) => {
const { engine, dataManager } = window.__godsEyeView,
mod = dataManager.layers.get('bhote-koshi-locator').module;
const pos = engine.projector.worldToWindow(world, {}),
box = engine.host.canvas.getBoundingClientRect();
return {
same: mod === window.qaLocator,
stats: mod.getStats(),
params: mod.getParams(),
at: { x: pos.x + box.left, y: pos.y + box.top },
};
},
fromDegrees(85.47741, 28.33299, 1600),
);
assert.equal(same, true);
assert.equal(stats.count, 1);
assert.equal(params.presentation, 'bhote-koshi-path-overview');
// Look for the cyan river in a scene crop away from the UI rails.
await new Promise((r) => setTimeout(r, 1000));
const crop = async () =>
sharp(await p.screenshot())
.extract({ left: 400, top: 320, width: 230, height: 180 })
.removeAlpha()
.raw()
.toBuffer();
const rendered = await crop();
await p.evaluate(() =>
window.qaLocator.setParams({ presentation: 'nepal-context' }),
);
await new Promise((resolve) => setTimeout(resolve, 500));
const baseline = await crop();
let added = 0;
for (let i = 0; i < rendered.length; i += 3) {
const contrast = rendered[i + 1] - rendered[i],
previous = baseline[i + 1] - baseline[i];
if (
rendered[i + 1] > 140 &&
rendered[i + 2] > 140 &&
contrast > 35 &&
contrast - previous > 18
)
added++;
}
console.log(
kind,
stats.coverage,
'cyan pixels added by the path',
added,
at,
);
await strict.check(
p,
'bhote-koshi-locator',
'same model; the flood path adds more than 50 cyan pixels to the same scene without it',
added > 50,
{ added },
);
await p.evaluate(() =>
window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' }),
);
}
await p.evaluate(() => {
window.qaLocator.setParams({ presentation: 'bhote-koshi-incident-places' });
window.qaLocator.setParams({ presentation: 'bhote-koshi-city-context' });
window.qaLocator.setParams({
presentation: 'bhote-koshi-trigger-record',
preview: true,
});
});
await new Promise((resolve) => setTimeout(resolve, 2500));
// The last of three quick presentation changes is the one that stays.
const last = await p.evaluate(() => ({
params: window.qaLocator.getParams(),
stats: window.qaLocator.getStats(),
}));
await strict.check(
p,
'bhote-koshi-locator',
'the last of three quick presentation changes is the one applied',
last.params.presentation === 'bhote-koshi-trigger-record' &&
last.stats.count === 1 &&
!last.stats.error,
{ presentation: last.params.presentation, count: last.stats.count },
);
await p.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bhote-koshi-locator', false, {
origin: 'user',
}),
);
assert.deepEqual(errors, []);
console.log(
'PASS Nepal locator flood path and presentation retained, trigger and places update; both engines',
);
});
Loaded 100 of 1262 files, more files were not shown because too many files have changed in this diff. Show more