Merge branch 'main' into main

This commit is contained in:
Gaurav Patidar authored and GitHub committed 2026-09-14 09:32:51 +05:30
commit b4d96c5b11
692 files changed
+198677 -69710

No files matched your search

+36 -3
View File
@@ -1,3 +1,6 @@
# Local development uses one file for browser and server keys.
# Copy to .env (ignored); this example contains names and placeholders only.
# Only explicitly selected browser keys are injected into the client bundle.
# God's Eye View — environment variables
# Copy to .env and fill in your keys. On macOS the launcher can also read keys
# from the Keychain (see README); on Linux/Windows use this file or env vars.
@@ -21,6 +24,16 @@
# available in the map tray and as the automatic provider-failure fallback.
# CLIENT-EXPOSED — restrict it (HTTP referrer + API restriction) in Google Cloud.
GOOGLE_MAPS_API_KEY=
# Optional: a SEPARATE Google API key for the SERVER-SIDE Google calls — Places
# (/api/google/nearby-places, /api/google/text-search) and the CCTV Street View
# fallback (#33). It never reaches the browser, so it can be restricted by
# server IP (not HTTP referrer) and scoped to just Places API + Street View
# Static API, letting the client-exposed key above stay narrowly scoped to Map
# Tiles + Geocoding. Unset, these calls fall back to GOOGLE_MAPS_API_KEY, so a
# single shared key keeps working — splitting is opt-in.
# SERVER-SIDE ONLY.
GOOGLE_MAPS_SERVER_API_KEY=
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
# (/api/google/nearby-places), in requests per minute.
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
@@ -134,9 +147,29 @@ AISSTREAM_API_KEY=
# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON
# CCTV_SOURCES_JSON= # inline source-pack JSON (overrides file)
# CCTV_AUSTIN_ROWS_URL= # override the Austin Open Data catalog URL
# CCTV_AUSTIN_MAX_SOURCES=36 # cap cameras loaded from the Austin catalog
# CCTV_MAX_SOURCES=48 # overall cap on registered cameras
# CCTV_AUSTIN_MAX_SOURCES=250 # cap cameras loaded from the Austin catalog
# CCTV_CALTRANS_DISTRICTS=4,7,11,3 # Caltrans districts; empty string disables Caltrans
# CCTV_CALTRANS_MAX_SOURCES=300 # cap cameras loaded from Caltrans
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
# CCTV_DRIVEBC_MAX_SOURCES=250 # cap DriveBC (British Columbia) cameras, nearest Vancouver/Victoria first
# CCTV_DRIVEBC_ENABLED=1 # 0 turns off the DriveBC camera pack
# CCTV_TXDOT_ENABLED=1 # set 0 to disable the TxDOT (Texas) pack
# CCTV_TXDOT_DISTRICTS=AUS,SAT # TxDOT district codes (25 exist); empty disables the pack
# CCTV_TXDOT_MAX_SOURCES=500 # cap cameras loaded from the TxDOT catalogs
# CCTV_TALLINN_ENABLED=1 # set 0 to disable the Tallinn (Estonia) intersection cameras
# CCTV_TALLINN_MAX_SOURCES=255 # cap cameras loaded from the Tallinn catalog
# CCTV_TARKTEE_ENABLED=1 # set 0 to disable the Tarktee (Estonia) road-weather cameras
# CCTV_TARKTEE_MAX_SOURCES=179 # cap cameras loaded from Tarktee
# CCTV_WARENDORF_ENABLED=1 # set 0 to disable the Warendorf (Germany) municipal webcams
# CCTV_NSW_ENABLED=1 # set 0 to disable the Live Traffic NSW (Australia) cameras
# CCTV_NSW_MAX_SOURCES=250 # cap cameras loaded from Live Traffic NSW
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force Austin-only sources
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag)
# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag)
+69
View File
@@ -0,0 +1,69 @@
name: Bug report
description: Something is broken, fails to install, or renders wrong.
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for the report. Reproduction steps and relevant errors help us investigate. If installation failed before you could run a command, say which command failed and why. Redact API keys, tokens, and personal information; do not paste your .env file.
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what you saw instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Fresh clone, `npm ci`, `npm run dev`
2. Open http://localhost:4173, click LOCATION, type "London"
3. See "Search failed" toast
validations:
required: true
- type: dropdown
id: install
attributes:
label: How you installed
options:
- Pinokio one-click
- Terminal (git clone + npm)
- Docker
- Other (say what in the description)
validations:
required: true
- type: input
id: env
attributes:
label: OS, Node version, browser
description: Include the versions you can find. Write "unknown" for anything unavailable.
placeholder: "Windows 11, Node 24.14.0, Chrome 151"
validations:
required: true
- type: input
id: commit
attributes:
label: Version or commit
description: Output of `git rev-parse --short HEAD`, or the release tag. If unavailable, say when you installed or last updated.
placeholder: "aacfa06"
validations:
required: true
- type: textarea
id: doctor
attributes:
label: Output of `npm run doctor`
description: Run this in the app directory. It reports which providers are configured without printing key values. If it cannot run, describe the failure instead.
render: text
validations:
required: true
- type: textarea
id: logs
attributes:
label: Server log and browser console
description: Paste the relevant error lines from the terminal or Pinokio log and browser console. If no log is available, explain what prevented it. Redact credentials and personal information.
render: text
validations:
required: true
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/bilawalsidhu/gods-eye-view/security/advisories/new
about: Report anything exploitable privately. Do not open a public issue for it.
- name: Setup and quick start
url: https://github.com/bilawalsidhu/gods-eye-view#readme
about: Install paths (Pinokio, terminal), provider keys, and what runs keyless.
- name: Known issues
url: https://github.com/bilawalsidhu/gods-eye-view/blob/main/docs/KNOWN-ISSUES.md
about: Check here before filing. Your problem may already be tracked with a workaround.
@@ -0,0 +1,39 @@
name: Feature request
description: Propose a new layer, data source, tool, or UI capability.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
The project only ships public data with a license that allows redistribution, and it does not build features for identifying or tracking individuals. Read the "Responsible & Open" section of the README before proposing a data source.
- type: textarea
id: problem
attributes:
label: Problem
description: What can you not do today, and who needs it?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should it do? Keep the first version small.
validations:
required: true
- type: textarea
id: source
attributes:
label: Data source, license, and attribution
description: For anything that shows data, link the feed or API, name its license, and say whether it works without a key. Write "none" for pure UI changes.
validations:
required: true
- type: dropdown
id: contribute
attributes:
label: Will you build it?
options:
- Yes, I plan to open a PR
- No, this is a request for someone else
validations:
required: true
+12
View File
@@ -42,6 +42,12 @@ jobs:
- name: Run setup policy checks
run: npm run doctor -- --json
- name: Check adopted formatting
run: npm run format:check
- name: Check package boundaries
run: npm run check:boundaries
- name: Run unit tests
run: npm test
@@ -70,6 +76,12 @@ jobs:
- name: Run the Pinokio install path
run: node scripts/pinokio-install.mjs
- name: Check adopted formatting
run: npm run format:check
- name: Check package boundaries
run: npm run check:boundaries
- name: Run focused onboarding tests
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
+22
View File
@@ -0,0 +1,22 @@
dist
.gev-cache
.gev-logs
screenshots
qa-shots
output
coverage
3d-models
pinokio/ENVIRONMENT
pinokio/.installed
src/data/local_data/
*.geojson
*.geojsonl
# Local configuration, generated assets and browser evidence
.env
.env.*
!.env.example
node_modules
.vite
.vite-*
*.min.js
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"tabWidth": 2,
"semi": true,
"endOfLine": "lf"
}
+371
View File
@@ -1,12 +1,358 @@
# Changelog
## ALPR camera locations
- Label the loaded camera count as nearby, show a purple-dot legend, and add
SHOW NEAREST to frame and select a loaded camera when none are on screen,
using the available 3D-tile or globe terrain height.
- Keep nearby camera markers and selection stable during rotation, use bounded
ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries.
- Add optional, source-labeled OpenStreetMap ALPR camera locations, bounded city queries,
cached-response and coverage notices, selection cards, share links, and voice toggles.
- Separate the request adapter, camera model, presentation, and instance lifecycle.
Source cancellation also guards late response bodies and rejects invalid query bounds.
## Release disabled infrastructure rendering
- Remove built Data Center, Dam and Submarine Cable entities when their layers
are disabled, avoiding retained visualizer work and entity memory.
- Keep parsed datasets cached for re-enable; rebuild entities without refetching.
## Camera layer components
- Separate camera source requests, placement, frames, projection, cards and calibration.
- Own visibility listeners and pending initialization within each layer lifetime.
- Preserve existing camera catalogs, URL families, geometry and playback behavior.
## Traffic and bikeshare components
- Separate traffic loading, animation, styling and lifecycle into factory-owned components.
- Give each flow source its own bounded decode cache and cancellation checks.
- Separate bikeshare registry, station requests, rendering, selection and proximity handling.
## Installation and context components
- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering.
- Retain source and ground-floor ownership in standalone composition; reject malformed
installation snapshots and ignore failures from cancelled requests.
## Satellite and mission layer components
- Separate catalog loading, orbit calculations, display, tracking and interaction
into instance-owned satellite components.
- Separate mission ingestion, paths, placement, cards, roster, replay and camera
operations, retaining existing layer controls and satellite coordination.
- Cancel late mission source work and reject malformed launch snapshots.
## Fire layer components
- Split fire source loading, state, rendering, cards, selection and viewport work
into reusable components with application-owned scene services.
- Cancel late refreshes, retain good data after malformed responses, and preserve
selection identity without repeating a user-selection notification on refresh.
## Earthquake components
- Separate earthquake snapshot loading, record validation, and display ownership.
- Cancel pending earthquake refreshes on disable or destruction, retaining the
last good snapshot after malformed or failed refreshes.
## September 8, 2026
Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering.
Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude.
Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG.
This changelog records public product changes. For the authoritative description
of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md).
## [Unreleased]
- Separate map source factories from switching and resource ownership; retain current source IDs, attribution and fallbacks.
- Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source.
- Separate submarine cable sources and rendering components, and export bundled geography lookup modules.
### Fixed
- Traffic now retries a failed destination after city navigation without a layer
toggle. Camera departure cancels pending work, arrival checks the final view,
and superseded requests cannot keep a newer view loading.
### Added
- Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area
highway cameras, with server-registered still URLs and attribution.
- CCTV Mesh adds Finland: Fintraffic road weather cameras, keyless, nationwide, 300 by default. Each camera view of a station is placed separately; ambient stills refresh on the source's 10-minute cadence (the active camera keeps the usual 10-second refresh).
- Add DriveBC highway cameras for British Columbia to the CCTV layer: the 250
nearest Vancouver and Victoria by default, with Open Government Licence –
British Columbia attribution. `CCTV_DRIVEBC_MAX_SOURCES` sets the cap and
`CCTV_DRIVEBC_ENABLED=0` turns the pack off.
- Add TxDOT highway cameras for Texas as a keyless CCTV pack: the Austin and
San Antonio districts by default (`CCTV_TXDOT_DISTRICTS` selects any of the
25), only cameras reporting Device Online, snapshots decoded from TxDOT's
JSON-wrapped JPEG for the official origin only.
- Add Estonia CCTV source packs: Tallinn intersection stills (`ristmikud.tallinn.ee`,
curated catalog) and nationwide Transpordiamet / Tarktee road-weather cameras
(DATEX2 locations + rotating JPEG URLs), with Tallinn city POIs and attribution.
- Add a Warendorf (Germany) source pack: the Stadt Warendorf Marktplatz webcam, with a
curated pose.
- Add Live Traffic NSW (Transport for NSW, CC BY 4.0) as a keyless CCTV pack: 217
Sydney and regional cameras with compass headings and view descriptions.
- CCTV monitor planes no longer clip into the terrain. The plane is lifted
rigidly by the largest clearance deficit over a 3×3 grid of support points
against the ground under each (the ground at the mount where nothing finer is
known), and the client honours pack ranges instead of inflating them to 220 m.
`src/data/local_data/cctv_ground_heights/` ships precomputed ground heights under
every camera's mount and plane footprint (3,445 of 3,446 cameras), aligned to work
with Google Photorealistic 3D Tiles; cameras with shipped heights are placed
with zero runtime sampling, and the rest resolve the ground under their plane
from the Re:Earth DEM on activation. The footprint lift is capped at 60 m
above the mount-based lift so a tower under a far edge cannot launch the plane.
- Press backtick (`) to toggle a rendered-frame-rate readout beneath the logo.
Typing fields retain the key; monitoring stops when hidden.
- Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services.
- Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place.
- Cancel pending vessel history during selection and layer teardown.
- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle. Preserve known military identities even when the source has no position for them.
- Split civil flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Cancel enrichment on teardown and resolve model assets through the application.
- Separate aircraft/vessel transport and normalization from layer rendering, preserving observation timestamps, altitude datums and optional history.
- Retain absent aircraft during partially admitted snapshots and bound source error messages.
- Drive share updates, Location feedback and Scene controls through immutable state snapshots and disposable subscriptions.
- Keep stale lookup/load completions from publishing accepted results and retain shot rows during playback progress updates.
- Export the existing Scene director with explicit playback and editing outcomes.
- Separate UI assembly from standalone engine wiring, with dedicated panel layout, position, notice and recording owners.
- Stop pending UI presentation and drag work during disposal; preserve accessible status text when stopping its decoration.
- Organize component styles behind the same ordered stylesheet entry and include 3D model controls in the current-state snapshot.
- Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback.
- Preserve shot-label identity on selection so double-click rename can complete.
- Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations.
- Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown.
- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal.
- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal.
- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction.
- Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup.
- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row.
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback.
- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown.
- Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior.
- Extract application shortcuts and shader-parameter controls into reusable UI
components, preserving inputs and cleaning up listeners on rebuild/disposal.
- Extract adaptive panel rail placement and measurement into reusable UI modules,
preserving obstacle clearance, responsive allocation, disclosure and scroll behavior.
- Extract shared surface keyboard handling for the welcome launcher and Provider
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
### Added
### Security
- Validate configured Google Places coordinates and text queries before rate
limiting or upstream requests; preserve the keyless capability response.
- Bound CCTV media response headers to 15 seconds and cancel error bodies.
Cap buffered snapshot downloads at 16 MiB while streaming.
- Cancel the active location lookup when its controls are disposed.
### Fixed
- Extract panel disclosure and hover/focus controls into a reusable module;
cancel their listeners and pending work during replacement and teardown.
- Reuse cached military aircraft during adsb.lol rate limits and server errors,
honor bounded retry delays, and preserve cached observation times and stale
indicators. Show installation zoom guidance without a false LOAD FAILED.
- GBFS rejects upstream redirects, caps streamed responses at 5 MiB, and keeps
its deadline active through body reads. Rejected downloads are cancelled.
- Split Overpass/installation search, regional briefing/weather, local voice
handlers and standalone key setup into focused modules. Preserve routes,
source behavior, tool schemas and credential restrictions.
- Restore data-provider routes under local build preview and return JSON 404s
for unmatched API requests. Credential editing remains development-only.
- Extract CCTV catalog/media and Radio Browser directory providers into focused
Node modules, preserving their routes and policies and isolating CCTV catalogs
by provider instance and application root.
- Simplify POWER UP to one Google Maps entry. Keep the optional server key
available through environment configuration without a second setup row or
missing-key reminder.
- Separate terrain, traffic, FIRMS and GBFS middleware into focused provider
modules, preserving local configuration, routes and cache/error behavior.
- Split satellite and launch-feed server providers into focused modules with
portable request URL builders, preserving routes and cache/error behavior.
- Keep landmark names when geocoding returns only address components, preventing
the United States Capitol annotation from moving to a Washington hotel.
Unrelated outlines leave the valid geocoded marker in place.
- Split aircraft and vessel server providers into focused modules for source
fetching, AIS records/tracks and shared request helpers; preserve existing
routes, local setup, fallback behavior and rendering.
### Changed
- Separate explicit browser build settings from standalone environment loading
and local provider middleware. Preserve provider behavior and root named exports.
- Rename standalone browser startup to `src/standalone/` and add a Node-only
`gods-eye-view/build/vite` export with checked package ownership.
### Development
- Extract application lifecycle and viewer exports. Split standalone startup into
scene setup, controls, layer registration, tools and loading UI. Startup failure
and terminal shutdown release acquired resources and cancel delayed work.
- Adopt Prettier tooling contributed by RohanDaCoder (#227), with an explicit
file scope, pinned formatter and Linux/Windows CI checks. Format the reusable
infrastructure modules and their consumer tests. Package boundary checks keep
those exports separate from app startup and local Node services.
### Fixed
- Reduce terrain-height timeouts when Re:Earth slows down. Batches are
sized against measured response latency on both browser and server to reduce
request timeouts, and a partial upstream failure now
keeps the heights that did resolve rather than discarding them. A position
the upstream answers with no height is reported as an absent reading instead
of a failed refresh, so the log distinguishes a slow or broken upstream from
one that simply has no value for a coordinate.
- Separate optional Google server credentials for Places and Street View from
the browser key, contributed by Tom-Neverwinter (#110). Provider Settings,
Pinokio's app-specific credential handling and setup diagnostics recognize
both keys. The Street View tool prefers the server key across environment
and `.env` sources. Existing single-key and keyless setups remain supported.
- Complete the first-run, view-target prewarm, cockpit-plates and floor-hold
browser harness renderer portability fixes contributed by Tom-Neverwinter.
macOS retains Metal; other platforms default to SwiftShader. Cockpit renderer
assertions and evidence labels follow the actual selected mode. Floor-hold
explicitly selects its measured 2D billboard mode and keeps its mesh and terrain assertions; software runs are not real-GPU evidence.
First-run QA now checks the existing attribution Escape-close/focus-return
behavior while preserving the launcher-underneath regression checks.
- Datacenter and dam factories are available through scoped package exports with
explicit context, overlay and render callbacks. The standalone app uses the
same implementation and bundled datasets.
- Local GeoJSON layers share concurrent loads, cancel pending fetches on destruction,
discard late results, and remove their entity-context records on teardown.
- Unchanged local infrastructure overlays no longer sustain idle rendering.
Ground samples wait for visible terrain to settle and cannot place a marker
below its loaded surface; roofs and valid below-sea-level heights are retained.
Already sampled markers also follow higher terrain as close-up tiles refine.
- Datacenter and dam marker stems use bounded, zoom-dependent active sets with
stable selection during camera motion. Close-up stems scale to the actual
camera distance; source totals and submarine cables remain unchanged.
- Keyboard focus rings now survive active/selected button styles across the
interface. Visual Styles, Location cities and points of interest, search,
Context/mission actions, Cockpit utilities, and sliders retain a distinct
focus indicator.
- A short Space press activates a focused control only on key release. Holding
Space for 500 ms blurs that control before push-to-talk starts, and release is
then consumed so it cannot also activate the old control. The same hold works
from the map or page background; text-entry controls remain protected.
- The Location disclosure is reachable with Tab and shows keyboard focus;
its city, point-of-interest, and search controls do too. Escape from inside
the tray returns focus to its disclosure and discards any unfinished search;
Escape on the disclosure itself closes the tray and clears that focus.
- Data Layers ON/OFF buttons show a keyboard focus ring independently of
their enabled and feed-status colors.
- Display buttons, layout selectors, mode buttons, and sliders show a visible
keyboard focus ring, including the controls used in Cockpit Display. Enabled
CCTV camera dropdowns also show keyboard focus.
- Context tabs keep a distinct keyboard ring when selected. Their existing
Left/Right arrow navigation continues to switch Contacts and Space Missions,
and both choices remain reachable through ordinary Tab navigation.
- Tabbing through the Space Missions roster now drives the same temporary globe
rotation and mission-marker highlight as pointer hover, without selecting the
mission. Keyboard and pointer previews no longer cancel each other.
- Radio power controls, Search Nearby Sites, and Clear Selected Layers retain
keyboard focus while their async work is busy. They expose that busy state to
assistive technology and ignore repeated activation until the work settles.
- Live Contacts results retain keyboard focus by contact identity when counts,
distance order, or pages refresh. If a focused contact departs or rotates off
the visible page, focus moves to the named explanatory note at the end of the
list and survives later refreshes there, so the next Tab proceeds beyond the
list instead of restarting at Contacts or silently selecting another contact.
- Cockpit Live Signals retains keyboard focus during live updates and contact
reordering, allowing Tab to continue to Display and Radio. If the focused
contact leaves the list, focus moves to the current briefing tab.
- Cockpit-only Display and Radio launchers show complete inset focus rings.
- Escape collapses the nearest expanded panel containing keyboard focus and
returns focus to that panel's disclosure when closing from its contents.
Escape on the disclosure itself closes without leaving the collapsed control
focused. Cockpit Contact and Live Signals panels follow the same nesting rule.
- Cesium's bottom-left Data attribution control and lightbox Close control are
in the Tab order and support Enter and Space. Close, Escape, and backdrop
dismissal restore focus and synchronize the disclosure state.
- CCTV testing uses the normal launcher for keyless startup, credential loading,
localhost binding, and explicit LAN-exposure warnings while retaining its
smaller source-pack limits.
- CelesTrak, Launch Library, terrain-height, and aircraft-enrichment failures
return generic error messages. Related diagnostics omit raw exception details
and upstream error bodies; response statuses and cache fallback remain intact.
Includes the security fixes contributed by Tom-Neverwinter in PR #171.
### Fixed
- Map Source keyboard opening retries focus until the selected tile is visible.
Leaving the disclosure, pointer interaction, or closing the tray cancels the
pending handoff so delayed work cannot pull focus back.
- Scope, Bloom, Sharpen, location search and generated style sliders expose
explicit accessible names. The first-run checkbox retains its native label.
- FIRMS records a source as successful only after appending its rows, avoiding
contradictory success/failure status if aggregation throws.
- Radio country filtering and voice country requests now resolve common English
names and exonyms that `Intl.DisplayNames`' primary label omits, so requests
like "play radio in Turkey" no longer fail closed (Turkey → Türkiye, plus
Myanmar/Burma, UAE, Holland, Swaziland, East Timor, Cabo Verde, Vatican).
Ambiguous names such as a bare "Congo" or "Korea" still fail closed.
- Mapped-site outages show their scheduled retry countdown and distinguish
known Overpass rate limits, timeouts, and query failures. Search feedback no
longer claims a refresh succeeded while the layer is unavailable or loading.
@@ -28,6 +374,31 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
- Existing cached refusals are now ignored immediately, including during
stale-data fallback. Concurrent identical requests share the same last-good
fallback when all mirrors refuse, without duplicating upstream requests.
- A keyless place lookup no longer remembers a network failure as "no such
place". A blip while Photon was answering used to be memoized for the rest of
the session, so the query kept returning not-found from memory on a network
that had since recovered. A miss is now cached only when every source
consulted actually returned a verdict.
### Added
- Keyless place search. The LOCATION search box and the `fly_to_location` voice
tool now resolve place names through Photon (komoot, over OpenStreetMap) when
no Google Maps key is configured — previously the lookup threw. Google stays
the primary path and is unchanged when it answers; the fallback also covers a
key whose Geocoding API is not enabled, which Google reports as HTTP 200 with
`REQUEST_DENIED`, so an empty result is the detector rather than an error.
- The same keyless fallback now covers the remaining two place lookups: map
annotations ("annotate the botanical garden") and the Radio layer's
"near \<place>" selection. Radio previously threw without a key, which
surfaced as a failed voice turn rather than as a station it could not place;
annotations silently failed to anchor. Annotation footprints match OSM on the
resolved feature's canonical name, so locality words in the request cannot
pull the outline onto a neighbouring building.
- Refresh vulnerable transitive dependencies and update browser/image tooling
to Puppeteer 25.10.0 and Sharp 0.35.4. Cesium remains on 1.138.0.
Browser QA awaits the new asynchronous executable-path lookup.
## [0.1.1] — 2026-09-01 — Installation and live-data fixes
+27 -2
View File
@@ -44,6 +44,15 @@ Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test
If you change runtime behavior, update `docs/CURRENT-STATE.md` and
`CHANGELOG.md` in the same PR. For a deeper catalog of what each harness
checks and what it needs to run, see [TESTING.md](TESTING.md).
## Checking a built app locally
Run `npm run build` followed by `npm run preview`. Preview serves the built
frontend and local data-provider APIs. Keep optional server credentials in the
ignored `.env`; browser keys are embedded during the build, so rebuild after
changing them. Provider Settings and `/api/setup/*` are development-only: edit
configuration through the development app or environment file. Unknown API
paths return JSON 404 responses. Vite preview is for checking a local build;
it is not a production server.
## Good first contributions
@@ -51,7 +60,7 @@ The highest-leverage places to jump in:
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
- **🛰️ Add or improve a data layer.** Each layer is one self-contained module in `src/data/<layer>.js` implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template.
- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `vite.config.js`) and executed client-side (`src/voice/gevActions.js`). Keep the tool surface tight and the responses honest (confirm only what actually happened).
- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `server/providers/openai/tools.js`) and executed client-side (`src/voice/gevActions.js`). Keep the tool surface tight and the responses honest (confirm only what actually happened).
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md).
@@ -59,7 +68,7 @@ The highest-leverage places to jump in:
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
- **UI lives in `src/ui.js`** (panels, HUD, styles, the control facade). **Layer logic lives in `src/data/<layer>.js`.** Keep them separate.
- **Secrets stay server-side.** Anything needing a private key goes through a Vite proxy in `vite.config.js`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
- `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first.
## Coding style
@@ -69,6 +78,22 @@ The highest-leverage places to jump in:
- Match the surrounding code — comment density, naming, and idiom.
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
## Formatting and reusable components
Run `npm run format` before submitting changes to adopted modules, then
`npm run format:check` and `npm run check:boundaries`. Formatting uses the
explicit file list in `scripts/format-scope.json`; add newly extracted modules
and tests there in a separate mechanical commit. Keep unadopted files consistent
with their surrounding style. CI checks all adopted files and package boundaries
on Linux and Windows.
Reusable package exports own their state and receive application operations
through explicit callbacks. They must not import the standalone bootstrap or
local Node services. Update `scripts/package-boundaries.json` and consumer tests
when adding an export or expanding a component. See
[formatting and component boundaries](docs/CODE-BOUNDARIES.md) for the current
ownership and adoption process.
## Pull requests
1. Branch off `main`.
+78 -33
View File
@@ -13,30 +13,40 @@ How to read this:
## Live sources (fetched at runtime — not stored in this repo)
| Source | Used for | License / terms | Attribution |
|--------|----------|-----------------|-------------|
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., *"Bringing Up OpenSky"*, IPSN 2014 + opensky-network.org |
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" |
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **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)" |
| Source | Used for | License / terms | Attribution |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" + "© OpenStreetMap contributors" |
| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" |
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy); mount and heading derived from OSM geometry, "© OpenStreetMap contributors" (ODbL) |
| **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" |
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
| **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario |
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **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)" |
### Notes on the live sources
@@ -45,6 +55,14 @@ How to read this:
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario.
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **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.
@@ -58,13 +76,14 @@ How to read this:
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
| Dataset | Folder | License | Commercial use? | Attribution |
|---------|--------|---------|-----------------|-------------|
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" (+ Open Infrastructure Map) |
| **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" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| Dataset | Folder | License | Commercial use? | Attribution |
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" (+ Open Infrastructure Map) |
| **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" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
### ⚠️ TeleGeography is bundled but NonCommercial
@@ -74,9 +93,35 @@ The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial*
The richer structured dataset is licensed separately/commercially by TeleGeography.
### ALPR camera mapping
The optional ALPR layer queries OpenStreetMap nodes tagged `man_made=surveillance`
and `surveillance:type=ALPR` (including case-insensitive semicolon multi-values)
through the shared Overpass proxy. Source/tag reference:
[OSM ALPR tagging](https://wiki.openstreetmap.org/wiki/Tag:surveillance:type%3DALPR).
No DeFlock software, branded assets or separate database is bundled. The layer
shows contributor-supplied locations and tags, not plate records, camera video,
current operating status, or exhaustive coverage. All manufacturers share the
ALPR category and marker color. Manufacturer, operator and camera type are shown
only when tagged, not inferred or independently verified. Every selected card
names OpenStreetMap in a smaller, muted source line beneath those details.
Queries are limited to a city-scale viewport (at most 3° per axis), at most
1,500 returned nodes, with outward-snapped query reuse and explicit stale/limited
coverage notices. A linked © OpenStreetMap credit appears for five
seconds when camera locations first display after enabling, then collapses;
Data attribution retains the full source/license entry, following the
[OSMF interactive-map guidance](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines).
OSM data remains separately attributed under ODbL; querying
at runtime does not replace the license obligations. ODbL permits commercial
use and does not license the application code. If publicly using a derivative
database, follow its share-alike and access requirements; keep independent
datasets separate rather than treating the whole collection as MIT data.
Overpass hosting capacity and usage policies are separate from the data license.
### ODbL share-alike (datacenters, dams)
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a *modified* version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a _modified_ version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
### NASA FIRMS acknowledgement
@@ -159,4 +204,4 @@ accepts inline JSON as an alternative to a file path.
## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
+4 -2
View File
@@ -2,6 +2,8 @@
# 🌐 God's Eye View
[![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml)
### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real.
Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic, and public cameras. Hands-free voice control powered by a realtime AI agent.
@@ -186,7 +188,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
![Moving from a full airport overhead down to close taxiway inspection with 3D flight models](docs/media/start-here/airport-ground-traffic-google-3d.gif)
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
![Diving into an Austin intersection with a live public camera projected into the 3D scene](docs/media/03-austin-cctv.gif)
@@ -273,7 +275,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~3,400 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
+18 -3
View File
@@ -13,13 +13,14 @@ Include repro steps and impact. We'll acknowledge, investigate, and credit you (
## How secrets are handled
The golden rule: **secret-bearing API keys stay on the server side.** The dev/preview server (Vite middleware in `vite.config.js`) brokers every request that needs a private credential, so the browser never receives one.
The golden rule: **secret-bearing API keys stay on the server side.** The dev/preview server (middleware under `server/providers/`) brokers every request that needs a private credential, so the browser never receives one.
| Key | Where it lives | How the browser uses it |
|-----|----------------|--------------------------|
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
### Two deliberately client-side keys — restrict them
@@ -28,7 +29,9 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys plus two non-secret CCTV feature flags. Everything else stays server-side.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side.
**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use.
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
@@ -42,9 +45,21 @@ launcher layout and logs the submitted values. The ignored file is local
plaintext, not encrypted storage. The macOS Keychain remains the stronger local
option when launching through `./scripts/dev-fresh.sh`.
### Configuring separate Google keys locally
Terminal development uses one ignored repository-root `.env` for both
`GOOGLE_MAPS_API_KEY` (browser) and `GOOGLE_MAPS_SERVER_API_KEY` (server).
The tracked `.env.example` documents both without credentials. Vite injects
only the browser key; sharing an environment file does not expose the server
key. Provider Settings presents only the browser key as Google Maps; configure
the optional server key manually in the environment file. Pinokio uses
its ignored `pinokio/ENVIRONMENT` instead, with app values and blanks taking
precedence over inherited global values. An absent server key retains the
browser-key fallback for existing single-key setups.
## Server-side proxy hardening
The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay:
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
+19
View File
@@ -218,3 +218,22 @@ While recording, call out anything in these areas — this is the feedback I mos
- **No planes:** OpenSky data may be momentarily sparse; scroll out or wait a poll cycle.
- **No GEV MIC button / voice errors:** `OPENAI_API_KEY` didn't load — use the console API for
the annotation tests and skip the voice-only ones (§2).
## Browser harness renderers
The first-run, view-target prewarm, cockpit-plates and floor-hold harnesses
select Metal on macOS and SwiftShader on other platforms. Cockpit-plates also
accepts `--swiftshader` on macOS; floor-hold retains `--angle=<backend>`.
Floor-hold explicitly selects 2D aircraft mode because it measures billboard
positions; the tracking suite covers the 3D handoff.
Software runs validate their assertions but do not establish real-GPU visual
correctness. Floor-hold retains both mesh and DEM checks: an unavailable mesh
oracle fails the run even when the DEM check passes. Record the backend with
any screenshots and run GPU visual checks separately when needed.
FIRMS refactoring can be checked without a configured server key using
`node scripts/qa-firms.mjs --url http://localhost:4173 --fixtures`. This explicit
fixture mode exercises populated display, aggregation, cards, keyless and stale
responses, and selection/camera handoff. It does not establish live-source
acceptance; omit `--fixtures` with a configured FIRMS key for that check.
+35
View File
@@ -0,0 +1,35 @@
import cesium from 'vite-plugin-cesium';
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
plugins = [],
googleApiKey,
cesiumToken,
host = 'localhost',
port = 4173,
} = {}) {
return {
plugins: [cesium(), ...plugins],
server: {
host: host || 'localhost',
port: parseInt(port, 10) || 4173,
allowedHosts:
host === '0.0.0.0' || host === '::'
? true
: ['localhost', '127.0.0.1', '.local'],
fs: {
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
},
// These headers protect the document containing Provider Settings.
headers: {
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
},
},
define: {
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
},
build: { chunkSizeWarningLimit: 1500 },
};
}
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
[
{
"id": "warendorf-marktplatz-rathaus",
"name": "Marktplatz / Historisches Rathaus",
"city": "Warendorf",
"provider": "Stadt Warendorf",
"sourceKind": "municipal-webcam",
"feedType": "image",
"url": "http://webcam.warendorf.de/image/jpeg.cgi",
"snapshotUrl": "http://webcam.warendorf.de/image/jpeg.cgi",
"lat": 51.9526613,
"lon": 7.9908867,
"headingDeg": 221,
"headingConfidence": "high",
"pitchDeg": -23,
"fovDeg": 84,
"rangeM": 260,
"mountHeightM": 14,
"groundElevationM": 55,
"poseSource": "curated",
"license": "Public municipal webcam data — Stadt Warendorf (courtesy). Mount and heading derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)."
}
]
+102
View File
@@ -0,0 +1,102 @@
# Application construction
`gods-eye-view/application` exports `createApplication`. Importing it does not
create a viewer, discover configuration, start requests, or attach browser
listeners. Construction is also inactive until the caller invokes `start()`.
The caller supplies four constructors, in this order:
| Constructor | Receives | Standalone implementation |
| ---------------- | ---------------------------------------------- | --------------------------------------------- |
| `createScene` | `signal`, `defer` | Viewer, attribution and initial map |
| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation |
| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration |
| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners |
Each constructor returns its component object, or a promise for that object.
Those objects are passed unchanged to later constructors. Configuration and
service instances belong in the caller's closures; the application does not
interpret provider names, environment variables, endpoints, or module paths.
There is no module discovery or automatic import mechanism.
```js
import { createApplication } from 'gods-eye-view/application';
import { createApplicationViewer } from 'gods-eye-view/application/viewer';
const app = createApplication({
createScene({ defer }) {
const viewer = createApplicationViewer({ container, creditContainer });
defer(() => viewer.destroy());
return { viewer };
},
createControls,
createData,
createTools,
});
const unsubscribe = app.subscribe(({ status, phase }) => {
// Update the caller's startup presentation.
});
await app.start();
// When the application is no longer needed:
unsubscribe();
await app.destroy();
```
The example's containers and three remaining constructors are supplied by the
consumer. The viewer helper preserves the standalone viewer's render settings;
it neither selects map sources nor reads keys. Supply a visible credit container
and retain all attribution required by the chosen sources. Cesium is external to
this export: the consuming build must provide the same instance used elsewhere
in its application.
## Lifecycle and ownership
Register `defer(cleanup)` immediately after acquiring each resource, before any
`await`. Cleanup registration remains open until that constructor settles. Each
callback may return a promise. Within a phase, callbacks run in reverse order.
Across phases, teardown runs **tools, controls, data, scene**: controls must cancel
restoration while their data manager and viewer are still alive.
`start()` returns the same promise on repeated calls. `destroy()` is terminal and
also returns the same promise on repeated calls. It aborts the shared signal
immediately, waits for an in-flight constructor to settle, and runs all registered
cleanup callbacks. Constructors must forward the signal to cancellable operations
and check it after awaits. If a provider cannot cancel construction, register
cleanup for its late result before returning. Destruction waits for that result;
it does not pretend a pending resource has already been released.
A constructor failure triggers the same cleanup before rejecting startup.
Cleanup failures do not stop the remaining callbacks; they are reported through
an `AggregateError`. Constructors own cleanup for resources created internally,
including anything allocated before their own construction throws.
`getState()` returns a frozen `{ status, phase }` snapshot. Status is `created`,
`starting`, `ready`, `destroying`, `destroyed`, or `failed`. `phase` identifies the
constructor during startup. `subscribe(listener)` immediately reports the current
snapshot and returns an unsubscribe function. Install observers before `start()`
to receive every startup phase. Observer failures cannot interrupt startup or
teardown. `getComponents()` returns a frozen, shallow snapshot of the currently
constructed component objects; the component instances themselves remain mutable.
`ready` means all constructors have returned. Background feed activity and the
standalone share-restoration result keep their existing separate contracts.
## Standalone wiring
`src/main.js` reads the existing browser configuration and starts
`src/standalone/application.js`. That module selects the four implementations
in its directory. Scene setup, controls, layer registration, tools and loading
chrome have separate owners. The existing `window.__godsEyeView` debugging shape
is preserved while the app is running.
The standalone controls and layer modules still contain page-scoped state.
Only one standalone application may be constructed per page. Shutdown releases
its runtime resources and debugging handles; reload the page to start again.
It is not an embeddable, removable HTML shell or a multiple-viewer implementation.
Consumers of the small application export supply their own component ownership;
the lifecycle controller itself has no shared instance state.
Later component extractions can replace a constructor's internals without
changing the startup contract or moving standalone imports into the package.
Run the unit suite, package boundary gate, build, tracking and first-run browser
checks when changing this wiring.
+387
View File
@@ -0,0 +1,387 @@
# Formatting and component boundaries
Run `npm run format` to format the files in `scripts/format-scope.json` and
`npm run format:check` to check that same list without writing. CI checks the
entire adopted list on Linux and Windows. Prettier is pinned in the development
dependencies; use the installed version so local and CI output agree. The shared
configuration specifies two spaces, single quotes, semicolons and LF endings.
Add new reusable modules and their tests to the list as they are extracted.
Keep mechanical formatting in its own commit after behavior is stable. Existing
source-text regression assertions still apply; investigate failures and preserve
their behavioral coverage when a move or line wrap changes a tested shape.
Files outside the list retain their surrounding style until deliberately adopted.
Generated output, local configuration, browser evidence and bundled datasets are
excluded. The formatter validates every entry before writing any file.
## Current component ownership
| Surface | Owns | Receives from its caller |
| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- |
| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations |
| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations |
| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements |
| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services |
| `src/main.js` and `src/standalone/` | Standalone browser startup | Local configuration |
The application and infrastructure exports are browser source modules. Use their documented
exports instead of importing standalone startup or reaching into internal files.
The application owns the viewer, context store, overlay host and render scheduler;
layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md).
`npm run check:boundaries` builds every declared package export, with app Vite
configuration disabled. `scripts/package-boundaries.json` lists each export's
component, owned modules and external runtime dependencies. A new export must be
classified. Imports outside the declared modules fail, including unused and
literal dynamic imports. Cesium stays external so the consuming application
supplies the same compatible instance as its viewer. Existing consumer tests
also check import-time inactivity and asset URLs under a non-root base.
These checks cover the declared exports, not every import in the application.
They check build-time imports, not arbitrary runtime-generated module URLs.
Keep runtime module discovery out of these exports. When extracting another
component, add its ownership and consumer tests together. Node services must use
separate entry points and their own checks when they become reusable; importing
them into a browser component is not supported.
`gods-eye-view/application` owns construction order, startup state, cancellation
and disposal of caller-supplied components. Its only owned module is
`src/app/application.js`. `gods-eye-view/application/viewer` separately owns the
standard Cesium viewer configuration in `src/app/viewer.js`; Cesium stays external.
Neither export imports standalone UI, layers, tools or configuration. See
[application construction](APPLICATION.md) for the contracts and current limits.
UI panels and individual source adapters remain future extractions. They should
become smaller modules with explicit lifecycle owners as their callers migrate.
## Build and standalone server configuration
`gods-eye-view/build/vite` is a separate Node-only export. `build/vite.js`
creates standard Cesium/Vite browser settings from explicit inputs. It imports
only the declared `vite-plugin-cesium` build dependency, discovers no environment,
and constructs no provider middleware. Call it from a Vite configuration:
```js
import { createBrowserViteConfig } from 'gods-eye-view/build/vite';
export default createBrowserViteConfig({
plugins: [],
googleApiKey: undefined,
cesiumToken: undefined,
});
```
Consumers supply compatible Vite and vite-plugin-cesium development dependencies.
The package's `node` export condition has no browser fallback. The boundary gate
builds this group for Node, with the declared build dependency external; its
owned module list is checked just like browser groups. Browser groups cannot
use build-only dependency exceptions.
`server/standalone/vite.config.js` loads the root environment and passes selected
browser keys, host/port and the ordered local provider plugins to this helper.
`server/providers/local.js` composes provider factories and re-exports existing
helpers for compatibility. Provider Settings lives in `server/standalone/key-setup.js`
and writes to the same root `.env` or Pinokio store as before. `vite.config.js`
preserves the default configuration and named provider exports for tools/tests.
## Aircraft and vessel providers
`server/providers/live.js` exports the existing Node middleware factories and
request helpers. `aircraft/` owns OpenSky state/fallback, military positions,
enrichment and track endpoints in separate files. `vessels/ais-live.js` owns
websocket setup and route responses; `vessels/ais-store.js` owns record ingestion,
static metadata and recent-track storage. Neither area imports globe rendering.
`common/http.js` owns capped reads/coalescing; `common/query.js` owns query values.
These plugins retain their existing process-scoped caches and server lifetime.
Importing the entry does not start acquisition. The AIS plugin disposes its
socket/watchdog on server close and re-reads configuration after restart.
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
records without importing Node middleware or a renderer. Browser layer/controller
separation is outside this server extraction.
## Place-search and routing providers
`gods-eye-view/server/providers/places` exports the Google nearby-place and
text-search plugin, OSRM route registration, and their shared Node helpers.
The existing Overpass plugin still mounts `/api/route` in its original order.
Google credentials are resolved on each request; the default uses the existing
server-key precedence, and callers may supply `resolveApiKey`.
`gods-eye-view/sources/places` exports portable response projections for Google
place results and OSRM route results/profile aliases. These functions own no
credentials, requests, caches, environment loading, or rendering. Callers retain
input validation and upstream-response acceptance. The boundary check builds
this entry independently and rejects Node imports.
The browser's direct geocoding, annotation rendering and full Overpass query
service remain in their existing modules.
## Satellite and launch providers
`gods-eye-view/server/providers/space` is a Node-only entry for the CelesTrak
and Launch Library 2 middleware. Separate files under `server/providers/space/`
own each feed's acquisition, memory/disk cache and error handling. The existing
local composition mounts them in the same order. Importing the entry performs
no acquisition; factory calls create independent cache state.
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
Callers supply the group or end date and own validation, credentials, transport,
response limits and cache policy. The boundary gate checks this portable entry
separately from the Node providers. Satellite rendering and launch replay remain
in their existing browser modules.
## Terrain, traffic, fires and bike-share providers
`gods-eye-view/server/providers/terrain`, `/traffic`, `/firms` and `/gbfs`
are separate Node-only entries. Each owns its existing middleware and
process-scoped cache or request handling. Standalone composition mounts them in
the original order; their imports do not start acquisition.
`gods-eye-view/sources/terrain` exports existing point-key, retry and cache
reconstruction mechanics with injectable acquisition dependencies.
`gods-eye-view/sources/traffic` exports tile math and budget calculations.
`gods-eye-view/sources/gbfs` exports host/path acceptance and cache-header rules.
These entries import no Node middleware, application configuration or rendering.
Callers retain their request admission and transport policy. FIRMS CSV parsing
remains an owned dependency of the FIRMS Node provider. The boundary gate
checks each entry independently.
Browser terrain sampling, traffic matching/drawing, fire overlays and bike-share
layer lifecycle remain in their current modules. This extraction changes no
source defaults, credentials, quotas, data interpretation or visual behavior.
## Local search, regional context, voice and setup
Node-only exports `server/providers/overpass`, `server/providers/military-installations`,
`server/providers/regional` and `server/providers/openai` own request handling
without importing the standalone configuration or browser rendering. Overpass
separates query admission, geometry simplification, cache and transport; military
search shares the bounded transport. Regional place/news/weather acquisition is
separate from briefing and weather-effect response caches. Voice handlers share
existing rate limits and request reading; schemas and instructions are separate.
`server/standalone/key-setup` is explicitly standalone Node functionality.
Its factory and the local voice factory accept `sourceRoot` for application-owned
configuration/log files; defaults resolve the repository root. Local voice also
accepts an optional `annotationGuidance` paragraph. Neither factory starts
acquisition on import. Setup retains its pre-environment-load provenance capture
and development-only registration. Package checks enumerate every owned module
and reject browser imports of these Node entries.
## Browser place search
`gods-eye-view/search` exports an explicit geocoding service and Google/Photon
adapters. The entry owns normalization, bounded caches, deadlines and fallback
sequencing. It imports no application state, environment configuration, rendering
or Node server code. Google transport is supplied by its caller.
`src/standalone/placeSearch.js` constructs the configured Google request and
keyless Photon fallback. The application passes this service to location
controls, annotation resolution and voice/radio actions. Those consumers retain
framing, landmark recovery, footprint matching and playback decisions. Existing
reverse geocoding and nearby/text-search routes remain separate.
## Panel controls
`gods-eye-view/ui/panels` owns collapse-button binding, nearest-panel Escape
handling, hover delays and delayed content-focus handoff. It accepts existing
DOM elements and callbacks; importing it creates no browser state. `destroy()`
removes owned listeners and cancels pending work without changing saved state
or moving focus. Call it before removing or replacing the controls.
`src/ui.js` retains panel layout, persistence, share restoration and application
reactions to state changes. Map Source selection and Location draft cleanup are
provided through callbacks. The component imports no globe, data, application
or server modules. Package checks and scoped formatting cover this entry.
## Surface keyboard handling
`gods-eye-view/ui/surfaces` exports `createSurfaceKeyboard` from
`src/ui/surfaceKeyboard.js`. It receives a root DOM node, an optional document,
an `isActive` predicate, an `onEscape` action and an optional return-focus fallback.
Construction is inert. `activate()` remembers the opener and installs one capture
listener; repeated activation is harmless. `deactivate({ restoreFocus: true })`
removes that listener and restores the opener when connected, otherwise invoking
the supplied fallback. Omit return focus when yielding to another surface.
`destroy()` permanently releases ownership without moving focus.
The welcome launcher and Provider Settings retain content, visibility, initial
focus, animation and screen-specific policy. Tab boundaries are read from the
current visible/enabled controls for each key; ordinary movement within those
boundaries remains native. The component honors already-handled keys and has no
app, server, storage or network dependencies. Its package boundary is checked
independently from the standalone screens that consume it.
## Panel rail layout
`gods-eye-view/ui/layout` exports synchronous `layoutLeftPanelRail` and
`layoutRightPanelRail` passes, `measurePanelNaturalHeight`, and the existing pure
corridor/allocation helpers. Separate modules own left placement, right placement,
DOM height measurement and rail geometry. They import no application, renderer,
server, storage or network modules; package checks build this entry independently.
Callers supply rail/obstacle DOM nodes, the viewport, HUD state, the preferred
panel, disclosure/retry callbacks and the left measurement cache. Right layout
reads the caller's Display scroll value at measurement time and restores it
within the resulting scroll range. The left pass notifies its caller after
alignment so the right pass can follow. Neither pass installs listeners, timers
or observers; construction/import does no work. Scheduling, preference writes,
share restoration and movement of controls between containers remain caller-owned.
Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain
compatible through re-exports.
## Visual input
`gods-eye-view/ui/input` exports `bindApplicationShortcuts` and
`createStyleParameters`. The shortcut binder owns one bubbling keydown listener
and receives the document, editing target and explicit action callbacks.
Parameter controls own only the supplied container's generated rows/listeners;
uniform metadata and read/write/change operations come from the caller.
Clearing permits reuse; destruction is final. Neither module imports the app,
renderer, persistence or services. The facade retains panel visibility, share
restore claims and render scheduling. Both controls are destroyed before the
facade's asynchronous teardown can yield.
## Display controls
`ui/display` owns Display button, selector and slider subscriptions. It receives
DOM elements and explicit actions, imports no application or effect singleton,
and releases every listener on destruction. Settings and rendering remain with
the caller.
## Visual effects
`ui/effects` exports the effects controller and existing preset definitions.
It owns shader stages and their clock, with explicit render ownership callbacks.
Construction installs no stages or frame callbacks. Stop animation before
releasing UI consumers, then destroy to remove owned stages and restore the
borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers
without loading the renderer. UI presentation and product-action coordination
remain in their callers.
## Map Source controls
`ui/maps` owns source-chip presentation, selection feedback and its subscription
lifetime. It receives the existing controller and explicit state/action callbacks;
it imports no renderer or application. Source construction and availability policy
remain with the map controller. Rebuilding controls removes their previous chip
listeners, and destruction suppresses late completions without owning or destroying
the supplied controller.
## Layer panel
`ui/layers` exports the Layers panel and clear-control binding. Callers supply
snapshots, row descriptors, subscriptions and actions; the component imports no
layer implementation or application bootstrap. Layer transactions remain with
the caller. Hidden-page refresh scheduling remains an explicit callback.
`ui/layers/feedback` exposes the existing pure loading/notice reducers separately
from DOM controls. Scheduling and presentation stay with their callers.
## Location controls
`ui/location` exports Location controls, the cancellable lookup controller and
the existing location-status formatter. Callers supply city data, search and
navigation operations. The component owns DOM listeners and pending expansion;
it imports no geocoder, camera engine, layer or application bootstrap. Existing
camera authority and search providers remain supplied by the application.
### Radio controls
`ui/radio` owns Radio input, disclosures, tuner state and presentation. It
receives the existing Radio port and explicit layer/layout actions, without
importing the renderer or station providers. Pure tuner calculations retain
compatibility exports from the data layer. Disposal revokes DOM listeners and
subscriptions before ending the active tuning interaction.
### Camera panel controls
`ui/cctv` composes camera controls, frame loading, calibration editing and status
presentation. It receives DOM elements, the existing camera port and explicit
application actions; it imports no provider, layer or camera engine. Selection,
placement, navigation and storage policy remain outside the component family.
### Context coordination
`ui/context` owns mode controls, transactions, session restoration and manager
subscriptions. Composition supplies the manager, installations search and
explicit visual/panel actions. `ui/context/policy` exposes the existing pure
mode and restoration rules. No source transport or renderer is imported by
these components; initial state and action results retain their existing shape.
## Cockpit controls
`ui/cockpit` supplies the Cockpit controller and Display portal. Camera updates,
instruments, Context readouts, briefings, signals, layout and input have separate
modules. Composition supplies the existing aircraft/awareness operations, terrain
cache and sampling operations, continuous-render owner and regional briefing
service. Pure math, utility layout and vision helpers have explicit exports.
Disposal releases subscriptions and pending work; portal moves preserve the
original Display groups, independent scroll positions and current focus owner.
## Scene controls
`ui/scenes` owns Scene prompts, panel input, project/shot rows, playback button and runtime
presentation. It receives project reads and explicit actions, with no imports of
the director, source modules, camera engine or storage. Replacement and disposal
release listeners; pending action feedback is limited to its current owner.
## UI assembly and styles
`ui/shell` assembles controls from supplied existing layer, navigation, terrain,
rendering, HUD and share operations. `src/standalone/ui.js` provides the running
application's instances; `src/ui.js` remains the compatibility entry. The shell
imports no standalone bootstrap or concrete live layer implementation.
Panel layout, position/drag, notices, recording and deferred UI work have separate
owners with synchronous cleanup. Existing scene, share and HUD engines retain
their entry points. `ui/styles` loads the ordered stylesheet entry; component
files retain the original cascade, including responsive and dock refinements.
## UI state and Scene actions
`StyleManager.subscribeShareState(listener)` supplies the current shareable
visual preferences and subsequent settings changes. The built-in share manager
consumes the same updates. `subscribeLocationSearch(listener)` follows the
current lookup owner across control replacement. `LocationSearch.subscribe`
provides the corresponding per-owner contract. Changes identify `started`,
`found`, `missing`, `failed`, `settled`, and the shell's `reset`; request IDs
belong to their lookup owner. Only current requests publish accepted results.
`gods-eye-view/scenes` exports `SceneDirector`. Its `subscribe(listener)` supplies
small playback snapshots plus editing outcomes. Scene controls consume these
updates to render the affected presentation; progress does not copy the project
or rebuild shot rows. Project import/export outcomes include the project;
shot editing outcomes include the affected shot and its index before deletion.
Camera, layer sequencing, storage and run-file download retain their existing
owners. Cesium remains an external dependency supplied by the application.
Each listener receives `{ state, change, revision, initial }` and subscriptions
return an unsubscribe function. Initial state is emitted by default; pass
`{ emitCurrent: false }` to receive only changes. Snapshots and outcomes are
immutable plain data. Reentrant publications retain delivery order; removing a
listener or destroying its owner prevents further queued delivery. These APIs
perform no network requests and discover no additional modules.
## Maps
`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`,
`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
standard sources and their setup/fallback policy. The standalone facade wires
these to the application's render governor.
A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
Each source has a user-facing `descriptor`, availability/reason, and either an
`imagery({ signal })` factory or a supplied `tileset`/`createTileset({ signal })`.
Imagery sources can share a terrain definition with a stable `id` and
`create({ signal })` returning `{ provider }` or `{ terrain }`. Cache IDs must
identify the same source for the lifetime of that registry. Sources may supply
trusted credit markup and construction/tile-error fallback policy. Provider
configuration stays in factories; descriptor values are the presentation API.
Factories should honor cancellation where their SDK supports it. The controller
also checks scene ownership after asynchronous work, so late results cannot
replace a newer selection. Supplied tilesets are caller-owned; factory-created
tilesets and provider caches are controller-owned. Construct a fresh controller
for a new viewer or configuration lifetime.
+787 -14
View File
@@ -1,7 +1,669 @@
# God's Eye View Current State
The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations,
not camera footage or plate records. City-scale queries use the existing Overpass
provider, with capped results, retry, cached-data and incomplete-coverage notices.
Its `./layers/alpr` entry exposes a per-instance layer factory and bounded source
adapter. Sources return normalized records, stale/coverage flags and their own
attribution; rendering does not parse Overpass payloads. The standalone
registration supplies selection, picking, terrain and
render services. Selected cards and Data attribution identify OpenStreetMap.
Layer state and the existing voice layer tools include `alpr-cameras`.
The row count explicitly says **nearby**: loaded records can be outside the
screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when
selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is
disabled with no loaded cameras or while following a tracked entity. This action
chooses from the current source snapshot and uses rendered surface height from
3D tiles or globe terrain when available.
Coverage follows a bounded neighborhood around the screen-center ground point,
with a radius of twice the camera-to-ground range (at least 1 km), rather than
extending to the horizon. Sky, dateline-crossing and wider-than-city views do not
query. Movement within an accepted or pending query reuses it. Markers retain
their Cesium identity and ground clamping while their geometry is unchanged;
refreshes preserve selection without replaying a click event.
Data Centers, Dams and Submarine Cables release their built Cesium data sources
and record references when disabled. Parsed datasets remain cached for the layer
lifetime, so re-enable rebuilds entities without downloading or parsing again;
this can take longer than simply revealing hidden entities. Destruction clears
the parsed cache as well. A disable during loading cannot leave a completed
build hidden in the scene.
CCTV exposes a factory through `./layers/cctv`. Catalog and health requests,
frame/media URLs, camera records, ground placement, geometry queues, playback,
projection, cards, calibration and interaction have separate components. The
standalone entry supplies application-owned scene, ground and activation services.
Each layer owns its state and visibility listener; destruction cancels source
reads and pending initialization. Malformed health responses retain prior health.
Existing catalog fallback, camera poses, frame pacing and coverage controls remain.
Traffic and bikeshare expose factories through `./layers/traffic` and
`./layers/bikeshare`. Traffic separates road requests, ingestion, animation,
flow matching, styling, viewport lifecycle and development timing. Each source
owns its decoded flow cache; cancelled bodies cannot refill it. Bikeshare
separates its city registry, station source, parsing, rendering, selection and
proximity lifecycle. Existing standalone sources, live/simulated labels, road
budgets, station availability and polling behavior are retained.
Installations and proximity context now expose `./layers/installations` and
`./layers/awareness` factories. Each owns its records, selection, presentation,
navigation and lifecycle. The standalone entries supply existing scene operations
and the aircraft/vessel/installation instances used for proximity queries.
Installation requests use a bounded source adapter; explicit nearby-place search
remains separate from ordinary mapped-site loading. Invalid snapshots retain the
previous display, and cancelled requests cannot publish a later failure state.
Viewport limits, saturation retry, placement, query caps and controls are retained.
Satellite and mission layers expose separate factories through `./layers/satellites`
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
and teardown. Source adapters retain the existing CelesTrak and Launch Library
endpoints; standalone entries provide scene services and the satellite dependency.
The mission factory separates paths, replay, camera, panel, overlays and placement.
Pending mission requests abort on disable/destruction and malformed launch
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
tracking intent, replay timing, controls and source attribution remain unchanged.
The fire layer now exposes `./layers/firms`: an instance factory with separate
snapshot requests, records, rendering, cards, selection, viewport scheduling and
terrain-anchor batching. The standalone entry supplies the existing FIRMS source
and scene services. Disable and teardown cancel pending refreshes; malformed
snapshots preserve the last good display. Refresh restoration keeps selection
identity without announcing a new user selection. Source/label, LOD thresholds,
card limits, fire identities, altitude placement and analyst records are retained.
Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns
its entities and request lifecycle; the application supplies the overlay host
and snapshot source. The standalone adapter keeps the existing USGS daily feed,
M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or
destroying the layer cancels pending work and ignores late results.
## Vessel components and sources
`src/data/aisLiveVessels.js` assembles `createVesselLayer` from the
`./layers/vessels` package entry. The factory owns feed lifecycle, keyed records,
rendering, selection, trails and cards in separate components. The standalone
entry supplies its AISStream source, scene services and existing row limits.
Incomplete observations retain missing keyed vessels for at most five minutes
since their last accepted receipt, within the renderer's row budget (plus the
existing selected-contact pin). Complete observations keep the existing removal
policy. Empty or failed observations preserve warm data with the existing feed
health warning and first-connect grace period. Source timestamps remain source
timestamps; receipt does not turn an unknown epoch into a fresh update.
A refreshed record keeps its identity while updating its history reference.
Selection changes, disable and destruction cancel pending trail requests; late
responses cannot refill a cleared or replacement trail. Heading and course,
sea-surface placement, click ownership and card selection policy are unchanged.
## Military-flight components and aircraft mechanics
`gods-eye-view/layers/military` exports `createMilitaryFlightLayer`. It uses the
same normalized observation contract as civil flights, with separate military
classification, styling, model and tracking policy. Each instance owns its
contacts, history, scratch objects, model loads and cancellation lifetime.
Applications supply the existing scene services and resolve model asset URLs;
`src/data/militaryFlights.js` keeps the standalone layer API and adsb.lol source.
A source may retain a bounded stale-status reason; the standalone cached-feed
behavior remains unchanged.
`gods-eye-view/aircraft` exports the existing shared classification, icon,
metadata, motion, altitude, model-anchor, proximity and selection calculations.
It also exports `createMilitaryRegistry`, an explicitly constructed owner for
known military identities and active-layer transitions. Its optional background
poll consumes the optional `getIdentities` capability, falling back to normalized
positioned records when a source has no identity-only capability. The adsb.lol
adapter preserves known identities without requiring positions; those entries
still cannot enter the renderer. Source replacement
and disposal abort pending work and clear retained identities; construction
starts no network request. Both standalone aircraft layers use one registry.
## Civil-flight components
`gods-eye-view/layers/flights` exports `createCivilFlightLayer`. Each instance
owns its contacts, histories, model collections, scratch objects and lifecycle.
State, ingestion, enrichment, motion/floor interpolation, rendering, tracking and
queries live in separate files under `src/layers/flights`. The standalone
`src/data/flights.js` assembles the existing OpenSky source and scene services.
Applications supply the existing floor/snap, geoid, picking, sprite, camera,
trail, focus, readout, context, aircraft presentation and render services.
The factory never constructs a second application registry. Configure a source
before initialization; replacing it while initialized is rejected. Model loads
use the supplied asset resolver, including the preload path. Enrichment receives
an abort signal and cannot update a later lifecycle after destruction. Existing
camera, terrain floor, trail, selection and measured model-size policies remain.
## Browser live-source observations
Flights, Military Flights and AIS Vessels obtain snapshots and optional history
through `gods-eye-view/sources/live`. The standalone adapters use the existing
same-origin routes. Aircraft observations distinguish barometric metres from
WGS84 ellipsoid metres and retain source position/contact epochs; vessel records
retain separate heading/course and sea-surface datum. History is a best-effort
addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot retains absent contacts for up to five minutes before
the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
Sources receive cancellation signals and check them after body parsing. The
layer's existing lifecycle and selection guards continue to reject late work.
## State and action outcomes
Share preferences, place lookups and Scene playback expose immutable snapshots
and disposable subscriptions. Share settings drive URL updates; lookup outcomes
drive Location labels and busy/error feedback. Superseded or disposed lookups
cannot publish accepted destinations. Each completion carries its own request
identity so an older completion cannot clear the current search indicator.
Scene controls consume playback state and editing outcomes from the director.
Progress updates carry a small playback snapshot and preserve shot-row identity;
editing outcomes include a copy of the affected scene or shot. Subscriptions
start with current state, isolate listener failures and stop on disposal.
`gods-eye-view/scenes` exports the same director used by the standalone app.
## UI shell and component ownership
The standalone entry composes the UI with the application's existing layer,
terrain, navigation and rendering operations. The shell receives those instances
and assembles the controls. Panel layout scheduling, position preferences and
active drags, loading notices, recording presentation and DOM lookup have focused
owners. Disposal revokes queued presentation and listeners before asynchronous
Context restoration, cancels an unfinished drag without saving it, restores the
recording HUD and releases status decoration without replacing accessible text.
The ordinary control snapshot includes the current 3D model toggle and mode.
`style.css` imports component styles in their original cascade order. Scene,
share, HUD and layer engines retain their existing behavior and entry points.
## Scene control ownership
Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation
and keyboard cancellation. The director supplies project reads and explicit
editing/playback actions while retaining persistence, camera and layer sequencing.
Shot selection updates the highlight without replacing the row, preserving
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
late file and failed-action completions cannot update removed presentation.
## Cockpit component ownership
Cockpit presentation is separated from its camera/controller behavior. Existing
layer, terrain and rendering operations are supplied by composition, retaining
the same tracked identity, ground acquisition, motion correction and cadence.
The Display portal owns group anchors, focus/scroll restoration and listeners.
Superseded portal frames cannot repaint old state or steal focus after disposal;
retained Cockpit actions cannot restart a disposed controller. Input, subscriptions
and queued panel work stop before asynchronous layer restoration; final camera
and portal cleanup follows that restoration.
## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions,
layer snapshots and restoration. The application supplies the existing manager,
installations search and camera/panel actions. Tab listeners and pending
presentation work stop during disposal; layer restoration retains its existing
compensation and latest-intent rules. Clear Selected Layers shares this owner,
so an older restore cannot replay over a newer Clear action.
## Camera panel ownership
CCTV controls receive the existing camera port and explicit application actions.
Frame loading, calibration editing and status display have separate modules;
providers, camera placement and navigation policy retain their existing owners.
Changing cameras invalidates old image callbacks and cancels an unfinished
calibration edit. Failed refreshes preserve settled pixels for the same camera,
while a newly selected camera never shows the prior camera's image. Disposal
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
## UI disposal
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
resize observer and window resize listener. These remain independent of the
Location component and are included in whole-UI browser teardown acceptance.
## Radio controls
Radio panel, compact controls and tuner presentation have a dedicated owner.
It receives playback, layer actions and layout callbacks; station ingestion,
marker placement, audio playback and camera policy stay with their existing
owners. Tuner calculations have a pure entry, with existing layer exports
preserved. Destruction removes listeners and state subscriptions before ending
tuning; a delayed Enable result cannot reveal or refocus removed controls.
## DriveBC CCTV source pack
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
Austin, Caltrans and TfL packs. The server reads the keyless camera list that
DriveBC.ca serves at `https://www.drivebc.ca/api/webcams/` and keeps cameras that
are switched on and published. Frame URLs are built from each numeric camera id
under `https://www.drivebc.ca/images/`; they are never taken from the payload.
DriveBC's eight compass orientations become high-confidence heading priors, and
its elevation (metres above sea level) seeds the ground height.
By default the 250 cameras nearest downtown Vancouver and Victoria load.
`CCTV_DRIVEBC_MAX_SOURCES` sets the pack cap (8–1200) and
`CCTV_DRIVEBC_ENABLED=0` turns the pack off. The default catalog cap rises from
900 to 1,050, the sum of the default pack caps, so no default camera is dropped.
The Open Government Licence – British Columbia attribution is registered in the
Data attribution popover.
## Location control ownership
City/POI rows, search/reset bindings, location readouts and the orbit indicator
have a dedicated component with explicit navigation actions. A separate lookup
controller cancels superseded searches and checks camera authority before flight
and result presentation. Existing search providers and camera handoff policy are
preserved. Replacing or closing a POI row cancels its pending expansion frame;
destruction releases listeners, pending searches and the orbit indicator.
## Layer panel ownership
Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners
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.
## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection
feedback. Loading remains with the supplied map controller. The active chip
follows the source actually displayed, including fallback; obsolete completions
cannot overwrite a newer selection. Refresh and destruction revoke old listeners
and destruction suppresses late UI updates. Available choices and setup behavior
remain unchanged.
## Visual effects ownership
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
and sharpen. Display supplies actions and renders settings; effect execution has
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
Stopping the controller revokes animation before asynchronous UI teardown; final
destruction removes its stages and restores the borrowed bloom configuration.
## Display control ownership
Display button, selector and slider listeners have a single destroyable owner.
The application supplies actions and retains effect settings, restore claims and
rendering. Native keyboard editing, model modes and current defaults are preserved.
Destroying the UI removes these listeners before asynchronous teardown.
## Application shortcuts and shader parameter controls
`ui/input` supplies the bubbling application shortcut listener and generated
shader-parameter rows. Number/style keys, H/O/V/F/D/C actions, native form-control
typing and Escape behavior retain their existing mappings. Capture-phase
surfaces continue to arbitrate their own keyboard events first.
The UI facade retains shader values, share-restore authority, render requests,
search dismissal, visibility and Cockpit portal policy. Parameter rows preserve
labels, bounds, steps and precision. Rebuilding rows removes their previous
listeners; disposal removes shortcuts and parameter listeners synchronously
before asynchronous application teardown.
## Adaptive panel rail layout
`ui/layout` supplies the left/right rail layout passes, natural-height
measurement and pure corridor/allocation helpers. The UI facade passes live DOM
nodes, obstacle nodes, HUD presentation, preferred panels and callbacks. It
retains observers, frame scheduling, saved/share preferences and Cockpit portals.
The left rail keeps its measured collapsed heights and obstacle-safe corridor;
the right rail follows its top baseline and preserves Display's scroll owner.
Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults.
## Surface keyboard lifecycle
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
focus shared by the first-run launcher and Provider Settings. Each caller
activates it while open and deactivates it on dismissal; destruction releases
keyboard ownership without moving focus. Reopening captures the current opener.
The launcher retains its hit-test/exclusive-surface arbitration and dismissal
preferences. Provider Settings retains its existing visibility and save policy.
Initial focus, transitions and DOM content remain with each screen. This
component does not add modal semantics or make the map inert.
## Panel disclosure lifecycle
Panel collapse buttons, nested Escape handling and dock hover/focus timing now
use `ui/panels`. The component receives existing DOM elements and callbacks for
state changes and content focus. Panel layout, saved state, share restoration,
Location draft cleanup and Map Source selection remain with their existing
callers. Listener and timer cleanup is synchronous when controls are replaced
or disposed, preventing old hover or focus work from changing a later view.
## Military feed cooldown and loading guidance
The adsb.lol military proxy reuses its last response during upstream 429/5xx
failures and observes Retry-After, bounded to 5–120 seconds (defaults: 30 seconds
for rate limits, 15 seconds for server errors). A failure with no cached data
still reports an error. Cached responses carry their age; the browser preserves
observation timestamps and marks fallback data stale instead of inventing fresh
positions or reporting a failed load. Fresh responses clear that stale state.
Mapped-installation zoom guidance appears in the layer row without counting as
a global loading failure. Guidance statuses do not suppress independent refresh
errors.
## Places and CCTV request bounds
With a Google key configured, nearby and text search reject missing, blank,
non-numeric and out-of-range coordinates before the opt-in limiter and upstream
request. Text search also requires a nonblank query. Keyless requests retain
their `configured: false` response.
CCTV media waits at most 15 seconds for upstream response headers and returns
504 on timeout. Its timer stops when headers arrive, so live bodies can continue
streaming; body idle deadlines are separate from this header deadline. Error
responses are cancelled. Buffered snapshots have a 16 MiB streaming cap; an
oversized image remains an upstream miss and uses the normal fallback chain.
The existing declared media size ceiling remains 64 MiB.
## GBFS upstream bounds
GBFS refuses upstream redirects and enforces its 5 MiB response cap while
streaming. The 12-second deadline includes reading the body, and rejected or
stalled downloads are cancelled. Development and preview use the same handler.
## Remaining local service modules
Overpass query validation, geometry simplification, disk caching and upstream
transport now have separate modules; military-installation search reuses that
transport. Regional briefing combines separate place, news and weather sources,
while weather effects uses only the weather source. Existing process-scoped
caches, rate limits, stale fallbacks and route ordering are preserved.
Local voice has separate HUD-summary, debug-log and Realtime-token handlers,
with tool definitions and instructions in dedicated files. The factory accepts
an optional `annotationGuidance` paragraph; its default instructions and all 28
tool definitions remain unchanged. `sourceRoot` resolves debug logs against the
application directory. Standalone key setup accepts the same directory option
for its environment store and preserves boot provenance, loopback/origin guards,
atomic credential writes and development-only registration.
Node-only package entries expose Overpass, military installations, regional
services, local voice and standalone key setup. Importing them starts no network
acquisition. Browser layer lifecycle, rendering and voice execution stay in their
existing modules.
## Local build preview
After `npm run build`, `npm run preview` serves the built app with the same data
provider routes as development, including aircraft, satellites, terrain, traffic,
FIRMS, GBFS, Overpass and CCTV/media. Unmatched `/api` requests return a JSON 404
in both modes instead of the application HTML. Browser routes retain SPA fallback.
Credential editing (`/api/setup/*` and Provider Settings) is development-only;
preview returns JSON 404 for those endpoints. Server credentials come from the
local environment; browser keys are captured at build time. Rebuild after changing
a browser key. Preview is for local build verification, not a production server.
## CCTV and radio provider modules
CCTV catalog acquisition, source normalization and frame/media delivery now live
in separate modules. Each CCTV factory owns its catalog and health state; its
`sourceRoot` option resolves relative source files against the application root.
Radio Browser station normalization, restricted outbound transport and directory
caching are separate modules. Node-only package entries expose both provider
factories. Routes, payloads, fallback behavior and existing dev/preview hook
registration are preserved; browser rendering is unchanged.
## Terrain, traffic, fire and bike-share provider modules
Local composition now imports separate Node modules for Re:Earth heights,
TomTom flow tiles, NASA FIRMS detections and GBFS station feeds. Existing routes,
plugin order, server-key selection, validation, disk caches, budgets, retries
and stale/error responses remain unchanged. Each has a Node-only package entry
under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile
math and GBFS source rules are available under `gods-eye-view/sources/`.
The browser layers and their rendering remain in their existing modules.
## Landmark annotation identity
When a landmark geocode contains only address components, annotations retain
its requested name for outline matching. A city or neighborhood address no
longer replaces the landmark's identity. Without a canonical feature name,
outline candidates must contain the geocoded anchor or closely match the
requested name; otherwise the annotation stays at its geocoded point.
Genuine feature-name components and existing administrative/monument matching
retain their established behavior.
## Satellite and launch provider modules
`server/providers/space/` owns the CelesTrak TLE and Launch Library 2 Node
proxies. Their routes, six-hour/15-minute caches, disk storage, stale fallback,
request coalescing and optional LL2 server token retain existing behavior.
The Node-only `gods-eye-view/server/providers/space` export supplies factories;
`sources/space` supplies fixed upstream URL builders with no I/O or environment
access. Callers retain validation, transport and response policy.
## Build configuration and local provider boundaries
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
this checkout's environment and constructs the local providers in their existing
order. `server/providers/local.js` is the composition and compatibility entry;
provider families own their middleware and process state in focused modules.
Provider URLs, key selection, cache behavior, setup restrictions and routes are
unchanged.
`gods-eye-view/build/vite` is a Node-only export for explicit browser build
settings: Cesium assets, caller-supplied plugins, browser key defines, server
binding and document/credential protections. It never reads an environment file
or constructs providers. The standalone caller owns those choices.
Browser startup now lives in `src/standalone/`. That directory contains only
browser code; Node configuration remains under `server/`. Application lifecycle
and viewer export paths are unchanged.
## Application startup and shutdown
The standalone entry now composes scene setup, controls, layer registration and
tools through the reusable application lifecycle. Map defaults, layer order,
share restoration, voice setup and the running debug handle retain their behavior.
The welcome card still waits for restoration and the loading-cover transition.
Startup failure cleans up acquired resources. Explicit application destruction
aborts construction, cancels pending playback/annotations and delayed welcome UI,
then releases controls, layers and the viewer. Destruction is terminal; the
standalone page must be reloaded to start again. The exported lifecycle and viewer
helpers do not import the standalone entry or discover configuration. See
[application construction](APPLICATION.md).
## Scoped formatting and package checks
`npm run format` and `npm run format:check` operate on the explicit adopted-file
list. `npm run check:boundaries` checks the dependency graph of all
current package exports in their declared browser or Node runtime; infrastructure owns its three implementation modules
and takes Cesium from the consumer. CI runs both checks on Linux and Windows.
The standalone app, layer behavior and public export paths remain unchanged.
See [component ownership and adoption](CODE-BOUNDARIES.md).
## Google browser and server keys
Local Places nearby/text search and the CCTV Street View fallback prefer
`GOOGLE_MAPS_SERVER_API_KEY`, falling back to `GOOGLE_MAPS_API_KEY` when the
server key is blank or absent. Only the browser key is injected into client
code. POWER UP presents one Google Maps entry for the browser key. The optional
server key is configured manually in the same ignored root `.env`, or Pinokio's
ignored `pinokio/ENVIRONMENT`; it is omitted from Provider Settings and its
missing-key count. Existing server keys and the single-key fallback remain
supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries.
The Street View headings tool uses the same server-first selection after
resolving environment overrides per variable; its explicit `--key` wins.
## Infrastructure marker visibility
Datacenter/dam registration uses fresh reusable factories with the application's
existing context, overlay and render functions. The scoped package exports do
not import standalone application globals; see
[the infrastructure interface](INFRASTRUCTURE-LAYERS.md) for lifecycle and asset
requirements.
Local GeoJSON layers coalesce concurrent enables into one load. Disabling while
loading keeps the result hidden; destruction aborts the fetch and rejects late
parse/add results. Destruction and failed setup remove owned context records,
so replacing a layer cannot retain stale entities or listeners.
Datacenters and dams retain their full datasets while limiting active marker
stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km,
and 420 below 200 km. Selection favors existing label priority and nearby
features, retains stable choices across small camera movements, and refreshes
during continuous motion. Disable/re-enable resets selection cleanly.
Close-up stem sizing uses camera-to-feature distance without a 5 km minimum.
Unchanged overlay entries do not request another frame; moved positions,
changed membership and re-enabling still publish. Ground sampling waits for
visible globe tiles to settle and uses valid loaded terrain as a floor while
retaining rooftop and below-sea-level elevations. A hidden globe does not
constrain photoreal geometry, and retry work remains bounded. Already sampled
nearby markers follow higher settled terrain on existing bounded walks, without
additional GPU samples or timers, when close-up tile detail refines the floor.
The submarine-cable renderer is unchanged. These limits bound active stem work;
they do not reduce the materialized entity count or establish an FPS gain.
## Keyboard interaction and focus
- Enter on the map-source disclosure opens immediately. A short Space press
opens it on key release. Either route focuses the selected source once visible;
a bounded retry handles delayed opening transitions, and closing the tray or
moving focus elsewhere cancels the handoff.
- Keyboard focus rings are a global interface state and stay visible when a
button is active or selected. Visual Styles, Location suboptions, Context and
mission actions, Cockpit utilities, native fields, and sliders use the same
visible-ring contract.
- A short Space press on a focused control keeps its native key-release action.
If it remains held for 500 ms, focus is checked again, the control is blurred,
and push-to-talk starts; releasing a claimed hold cannot activate the old
control. The same hold works on the map and page background. Text-entry
controls remain protected.
- The Location disclosure is reachable with Tab or Shift+Tab and shows a
keyboard focus ring. Its city, point-of-interest, search-toggle, and search
field controls show inset rings, including selected items. Enter toggles its
tray immediately, while Space does so on release; either route makes the revealed controls immediately reachable by Tab;
Escape closes it and clears any unfinished search. Escape from a tray control
returns focus to the disclosure; Escape on the disclosure clears focus after closing.
- Data Layers ON/OFF buttons show a visible keyboard focus ring without
changing their enabled state or feed-status presentation.
- Display controls and shader-parameter sliders show keyboard focus in both
the map panel and Cockpit Display. Arrow keys retain native range adjustment.
The enabled CCTV camera dropdown shows keyboard focus as well.
- Tab reaches both Contacts and Space Missions in sequence in every Context
state. Left/Right arrows also switch them; the focused tab has a distinct ring
even when selected.
- Keyboard focus on a Space Missions roster item uses the same temporary globe
rotation and mission-marker highlight as pointer hover. Focus alone does not
select the mission; Enter or Space performs selection. Keyboard and pointer
preview ownership remain independent when the pointer is parked over the list.
- Radio power controls in the full, compact, and Cockpit surfaces, Search Nearby
Sites, and Clear Selected Layers remain focusable while lifecycle work is in
progress. They announce busy/disabled semantics and ignore repeat activation
until the operation settles, so async work cannot drop the keyboard ring.
- Normal-mode Contacts results preserve the focused action or contact by stable
identity while live counts, distance order, and pages refresh. If that contact
departs or rotates off the visible page, focus moves to a stable continuation
point on the named explanatory note at the end of the list and remains there
through later repaints. The following Tab proceeds beyond the results instead
of restarting at Contacts or silently focusing another contact.
- Cockpit Live Signals updates existing contact buttons without replacing or
disconnecting the focused one. Tab can continue through the briefing footer
to Display and Radio. If the focused contact leaves the list, focus moves
once to the current briefing tab; later refreshes do not reclaim it.
- Cockpit-only Display and Radio launcher icons show a complete inset keyboard
ring in their collapsed and expanded states.
- Escape collapses the nearest expanded panel containing focus before any
containing panel acts. Closing from panel content returns focus to that panel's
disclosure; closing from the disclosure itself clears focus so the collapsed
button does not retain its ring. This includes standard panels, nested
Parameters, Cockpit Contact and Live Signals, and Cockpit Display/Radio utilities.
- The required bottom-left Data attribution control is a named popup button in
the Tab order. Enter opens immediately and Space opens on release, then the
credit lightbox focuses Close;
Close, Escape, or backdrop dismissal restores focus to Data attribution.
## CCTV launcher and proxy failure responses
`scripts/dev-cctv.sh` delegates startup to `scripts/dev-fresh.sh`. It retains
its Austin source file, Austin preference, 36-camera Austin limit, and 48-camera
total limit, with environment overrides. Keys are optional; credential loading
and names-only provider provenance follow the normal launcher. The default
binding is localhost. An explicit `HOST=0.0.0.0` uses the same LAN warning as
normal startup.
The CelesTrak, Launch Library, terrain-height, and ADSBDB middleware return
fixed messages for unexpected failures. Launch Library retains its upstream
HTTP failure status and no-store policy but does not forward the upstream body.
Existing successful, in-flight, missing-data, and stale-cache behavior remains
in place. Failure diagnostics identify the service and, for Launch Library,
HTTP status without printing raw exception text or upstream bodies.
## Map Source keyboard focus
Keyboard opening focuses the selected map-source tile, falling back to the first
only when no source is selected. A bounded retry handles delayed tray visibility.
Moving focus away, pointer interaction, closing the tray, or disposing the UI
cancels pending work; a later reopening cannot inherit an old focus request.
## September 8, 2026
Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering.
Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude.
Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG.
Updated: August 24, 2026
## Aircraft and vessel server modules
Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky
state vectors and regional fallback, adsb.lol military positions, ADSBDB
enrichment and track backfill. Vessel routes and websocket/watchdog setup live
in `server/providers/vessels/ais-live.js`; AIS records and recent tracks live
in `ais-store.js`. Common response caps, request coalescing and query parsing
have their own modules. `server/providers/local.js` composes these with the
remaining providers and retains existing named compatibility exports.
`gods-eye-view/server/providers/live` is a Node-only entry for the existing
plugins and shared request helpers. Importing it starts no sockets or timers.
The existing aircraft normalizer is separately available through the portable
`gods-eye-view/sources/adsb-lol` export. Provider URLs, local credentials, cache
policy, fallback behavior, response shapes and rendering remain unchanged.
## Control names for assistive technology
Scope, Bloom, Sharpen and location search have explicit accessible names.
Generated style sliders use the same name as their visible parameter label.
The first-run suppression checkbox keeps its native wrapping label, so its
accessible name remains "Don't show this again". Control behavior is unchanged.
## FIRMS source status
The FIRMS proxy records source success after appending its rows. If aggregation
throws, that source reports failure without a contradictory success entry.
The existing per-record append continues to support large feeds; sequential
fetching, trailing-24-hour filtering and partial-success caching are unchanged.
## Installations and map-source guidance
- On an uncached Overpass failure, mapped installations keep their existing
@@ -748,7 +1410,7 @@ This is the current runtime/source-of-truth snapshot for the project.
> corridor. Layer toggles stay live from there, and collapsing returns the
> plain launcher. The map-only Clear, Share, and Reset Globe actions are hidden
> for the duration of Cockpit, both as a group and as individual controls.
> It uses the `radar` symbol and provides roving keyboard tab navigation. Its action row
> It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row
> places the single Cockpit entry before Search Nearby Sites. Cockpit removes
> the duplicate floating map entry and topline exit; the bottom-center
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus
@@ -934,12 +1596,20 @@ This is the current runtime/source-of-truth snapshot for the project.
> settled dim contact always completes its release after tracking ends.
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
> 5-decimal points individually, reconstructs reordered/overlapping batches
> in exact request order, and refreshes only missing or stale points. Network,
> 429, and 5xx failures receive bounded jittered retries with `Retry-After`;
> stale real heights remain usable per point, while an uncached absent height
> still returns 502 rather than becoming a fabricated ground value. Client
> geoid fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on
> the first later successful fetch.
> in exact request order, and refreshes only missing or stale points. Upstream
> calls and browser requests are both chunked at 64 points, sized to fit
> their 30s deadlines across Re:Earth's observed 87-186 ms/point range; a chunk
> that still fails contributes nulls for its own positions instead of
> discarding the chunks that resolved. Network, 429, and 5xx failures receive
> bounded jittered retries with `Retry-After`; stale real heights remain
> usable per point, while an uncached absent height still returns 502 rather
> than becoming a fabricated ground value. A position the upstream answers
> with a null ellipsoid is reported as an absent height rather than a refresh
> failure, and is left uncached so a later poll re-asks it. Client geoid
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
> first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved
> placement continues to use the existing prior until real heights arrive.
> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests,
> then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its local 90/min limiter. Cache and single-flight responses
@@ -1618,7 +2288,7 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven |
| CCTV | Austin + Caltrans (CA) + TfL London Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
@@ -1684,9 +2354,11 @@ preserves object identity for an idempotent repeat of the same generation, and
degrades without replacement if a fresh response presents an older generation.
Snapshot records mark community metadata as untrusted, and Radio tool results
omit station names so directory text never becomes model instruction context.
One bounded country parser maps recognized ISO codes and English/common names
through proxy metadata and final station selection, while malformed, non-ISO,
control-containing, and oversized inputs fail closed. Literal or resolved
One bounded country parser maps recognized ISO codes and English/common names —
including widely used exonyms the Intl display label omits (Turkey, Holland,
Burma, and similar) — through proxy metadata and final station selection, while
malformed, non-ISO, control-containing, ambiguous, and oversized inputs fail
closed. Literal or resolved
non-global IPv4/IPv6 targets are refused. Destroy fully releases the Radio audio
session, voice ducking/restoration, request state, filter, selection, volume,
accepted snapshot, and feed telemetry before re-initialization; monotonic
@@ -1967,7 +2639,16 @@ silently demoting every later lookup for the session.
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
sources — ~800 cameras total, all RAW PRIOR poses, stills-first.
sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes
(cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR
poses, and the layer is stills-first.
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
covering the whole country, where one *preset* (a station's fixed view) is one camera — 806
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
across packs, so a lower global cap thins every region instead of starving the last pack.
~1,100 cameras total, all RAW PRIOR poses, stills-first.
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
validated 2026-07-21): the COVERAGE toggle is a
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
@@ -2000,7 +2681,8 @@ silently demoting every later lookup for the session.
2026-08-02): the LOD-selected nearby static cameras (20/28/40 by zoom,
`cctvLod.js`) get **screen-space thumbnail cards** through the shared world-overlay host
showing paced static frames — reselection on `camera.moveEnd` only, at most one frame fetch
per second layer-wide, per-source cadences (Austin 5 min, TfL/Caltrans 3 min). Zero-flicker:
per second layer-wide, per-source cadences (Austin 5 min, TfL/Caltrans 3 min, Fintraffic 10 min
— its stations publish on a 600 s collection interval). Zero-flicker:
a card renders nothing until its first frame, a drawn frame persists through failed fetches,
and eviction grace (2-pass/5 s) stops budget-edge churn. Camera icons stay visible at every
zoom. Eligible candidates are filtered to in-view stills with valid IDs,
@@ -2200,6 +2882,29 @@ silently demoting every later lookup for the session.
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
### Place-search providers
Location search/fly-to, annotations and Radio location lookup receive one
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
Google first when configured and Photon/OpenStreetMap as fallback, including
Google transport failures or declined requests. The portable `./search` export
provides the service and adapters; it reads no environment or application state.
Existing browser/server key setup is unchanged.
Providers normalize coordinates, canonical name, label, place types and optional
bounds. Camera framing, nearby landmark recovery and footprint selection remain
in their consumers, including the Capitol identity/containment safeguards.
Radio keeps localized country names in labels rather than station filters.
Reverse geocoding and nearby/text-search endpoints retain their existing behavior.
Only valid answers and definitive misses enter bounded caches; malformed replies,
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. Invalid/wrapped bounds
are omitted rather than framing the wrong part of the globe.
### Map Stack Switcher (June 2026)
- `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, the default when a Google or ion key is present), keyless Esri World Imagery (the zero-key default landing, with keyless terrain), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`.
@@ -2298,7 +3003,7 @@ silently demoting every later lookup for the session.
eight-second timeout; the timer is cleared on every success or failure path.
- OpenSky response cache stores successful upstream responses only; OAuth token refresh calls are coalesced.
- A cold OpenSky failure uses the current camera subpoint only to request a cached adsb.lol point fallback capped at 250 nm. A fresh OpenSky response or last-good cache wins; a nominally successful worldwide snapshot more than two minutes old prefers viewport-scoped adsb.lol when available, otherwise the stale source is reported honestly. The fallback is visibly source-labeled and is never presented as a worldwide snapshot.
- GBFS response size is capped; CCTV health map is bounded.
- GBFS proxy refuses upstream redirects (`redirect: 'manual'`; any 3xx becomes a 502 and the redirect target is logged server-side only) and enforces its 5 MB response cap while the body streams, cancelling the upstream read past the cap; CCTV health map is bounded.
- Proxy error payloads are sanitized (no internal error details returned to clients).
- `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`.
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache.
@@ -2611,3 +3316,71 @@ Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the
## Maintenance Rule
When runtime behavior or architecture changes, update this file in the same change set as code updates.
## Dependency security baseline
The lockfile uses DOMPurify 3.4.15, protobufjs 8.8.0, PostCSS 8.5.28, and
nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0;
image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
### Traffic city navigation
Traffic checks the final camera view on arrival and retries a failed road request
while the camera is stationary, backing off from 1.5 to 30 seconds. Failed road
requests report an unavailable source. Leaving the traffic altitude range or
disabling the layer cancels pending work; superseded road and flow requests cannot
release the current request or keep its loading indicator active.
### Optional frame-rate readout
Backtick (`) toggles an FPS readout beneath the title logo. It counts actual
Cesium post-render events over one-second windows and does not request extra
frames. Typing fields, modified keys and key repeats do not toggle it. The
readout starts hidden each session and releases its timer and frame listener
when hidden or when the application is disposed.
## Radio components
The radio entry composes one layer from directory ingestion, station queries,
selection, globe rendering and playback modules. Its source supplies directory
metadata and click reporting; scene, ground and overlay services are provided
explicitly. Each constructed layer owns its catalog, audio and lifecycle state.
Existing catalog validation, category filters, tuning and voice playback behavior
remain unchanged. Audio connects directly to the broadcaster after an explicit
play action; the source does not relay or record streams.
## Bundled geography and submarine cable components
Submarine cables use separate source, geometry, rendering, interaction and
lifecycle modules. The layer factory accepts cable and landing-point GeoJSON
collections from a source with `fetch(signal)` and a display label. The default
source loads the same bundled TeleGeography files. Disabling still removes all
three Cesium data sources; enabling rebuilds from the accepted parsed cache,
and destroying clears it. Load ownership prevents cancelled work from adding
entities after teardown.
Natural Earth regions and neighborhood polygon lookup are package exports.
Their existing lazy loaders, retry behavior, bundled datasets and attribution
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
the project's MIT license; see `DATA_SOURCES.md`.
## Map source factories and coordination
Map selection is composed from separate imagery, terrain and 3D factories.
The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and
OSM, including their setup guidance and existing preset/share IDs. Esri still
falls back to OSM on construction failure or two active tile failures, and
credits follow the source actually displayed. Terrain remains lazy while the
photoreal globe is hidden.
Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults.
The controller accepts other source registries without adding provider branches.
Each instance caches source construction, ignores superseded scene changes and
releases imagery layers/listeners on replacement. Destroy invalidates pending
work and releases owned resources, including late factory results. Supplied 3D
tilesets remain owned by the caller; tilesets created through the controller's
factory are added to its viewer and removed on destruction.
+62
View File
@@ -0,0 +1,62 @@
# Reusing infrastructure layers
`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
It returns fresh datacenter and dam layers in that order, with the existing IDs,
appearance, label budgets and bundled public datasets. Importing the module or
calling the factory does not load data, create a viewer, or start the application.
The asset URLs resolve relative to the module; Vite rewrites them for production
base paths. This browser source export expects a Vite-compatible asset build.
Pass the application's existing functions:
```js
import { createInfrastructureLayers } from 'gods-eye-view/infrastructure';
const layers = createInfrastructureLayers({
overlayHost: { setEntries, setVisible, clearSource },
registerEntityContext,
selectEntityContext,
clearSelectedEntityContextForLayer,
removeEntityContextsForLayer,
governorRequestRender,
});
// Register these layers with the existing data-layer manager and viewer.
```
| Function | Arguments / responsibility |
| --- | --- |
| `overlayHost.setEntries` | `(sourceId, entries, options)`; publish the existing infrastructure overlay records and paint budget |
| `overlayHost.setVisible` | `(sourceId, visible)`; toggle a source |
| `overlayHost.clearSource` | `(sourceId)`; remove that source's overlay entries |
| `registerEntityContext` | `(entity, metadata)`; register selectable feature metadata, including the owning layer and data source |
| `selectEntityContext` | `(entity)`; select that registered feature |
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`.
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay
host permits one live instance per layer ID; destroy an old instance before
replacing it. Separate applications may use identical IDs with separate hosts.
Callbacks must be ready before enable and remain usable through destruction.
Disable retains the loaded dataset for reuse and clears selection and listeners.
Concurrent enables share one load. Destroy is permanent, aborts pending fetches,
discards late results, and clears the owned data source, context records, overlays,
input handler and listeners. The layer never creates an application context store,
overlay host, viewer, or render scheduler. The consumer must resolve one compatible
Cesium installation shared with its viewer; it must not bundle separate copies.
`gods-eye-view/infrastructure/geojson` exports the lower-level
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
The standalone app's `src/data/localGeojson.js` keeps its original single-argument
factory and supplies its existing functions. Other consumers should use the package
exports, which do not import standalone application globals.
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
expand multipart geometries into multiple entities; entity and stem counts are
different measurements. This change does not refresh or relicense the datasets.
+10 -7
View File
@@ -440,7 +440,7 @@
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" title="Scope edge feather as a percentage of the keyhole radius" />
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
@@ -460,7 +460,7 @@
<span class="pp-label">Bloom</span>
</button>
<div class="pp-slider-row" id="bloom-slider-row">
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" />
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
@@ -470,7 +470,7 @@
<span class="pp-label">Sharpen</span>
</button>
<div class="pp-slider-row" id="sharpen-slider-row">
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" />
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
@@ -548,14 +548,16 @@
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION">
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
</div>
<div class="dock-popover-content">
<div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
<div id="poi-row" class="poi-row-container"></div>
<div class="location-bar-divider" id="location-bar-divider"></div>
@@ -565,7 +567,7 @@
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" />
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" />
</div>
</div>
</div>
@@ -727,7 +729,7 @@
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span>CONTACTS</span>
</button>
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="-1" aria-selected="false" aria-controls="context-missions-view">
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span>SPACE MISSIONS</span>
</button>
@@ -765,6 +767,7 @@
<div class="space-mission-roster-list" data-mission-roster-list>
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
</div>
</div>
</section>
+372 -1279
View File
File diff suppressed because it is too large. Load diff
+116 -4
View File
@@ -2,7 +2,7 @@
"name": "gods-eye-view",
"private": true,
"version": "0.1.1",
"description": "A real-time intelligence console for planet Earth — 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",
@@ -33,6 +33,8 @@
],
"scripts": {
"doctor": "node scripts/setup-doctor.mjs",
"format": "node scripts/format.mjs --write",
"format:check": "node scripts/format.mjs --check",
"dev": "vite",
"dev:secure": "./scripts/dev-secure.sh",
"opensky:import": "./scripts/opensky-import-client.sh",
@@ -40,7 +42,8 @@
"preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs"
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"check:boundaries": "node scripts/check-package-boundaries.mjs"
},
"dependencies": {
"@mapbox/vector-tile": "^3.0.0",
@@ -51,10 +54,119 @@
"satellite.js": "^6.0.2"
},
"devDependencies": {
"puppeteer": "^24.37.5",
"sharp": "^0.34.5",
"prettier": "3.9.6",
"puppeteer": "^25.10.0",
"sharp": "^0.35.4",
"vite": "^6.0.0",
"vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0"
},
"exports": {
"./infrastructure": "./src/data/infrastructure.js",
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
"./build/vite": {
"node": "./build/vite.js"
},
"./server/providers/live": {
"node": "./server/providers/live.js"
},
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
"./server/providers/places": {
"node": "./server/providers/places.js"
},
"./sources/places": "./src/data/placeProviderPayloads.js",
"./server/providers/space": {
"node": "./server/providers/space.js"
},
"./sources/space": "./src/data/spaceProviderRequests.js",
"./server/providers/terrain": {
"node": "./server/providers/terrain.js"
},
"./sources/terrain": "./src/data/terrainHeightsProxy.js",
"./server/providers/traffic": {
"node": "./server/providers/traffic.js"
},
"./sources/traffic": "./src/data/tomtomTiles.js",
"./server/providers/firms": {
"node": "./server/providers/firms.js"
},
"./server/providers/gbfs": {
"node": "./server/providers/gbfs.js"
},
"./sources/gbfs": "./src/data/gbfsSource.js",
"./server/providers/cctv": {
"node": "./server/providers/cctv.js"
},
"./server/providers/radio": {
"node": "./server/providers/radio.js"
},
"./server/providers/overpass": {
"node": "./server/providers/overpass.js"
},
"./server/providers/military-installations": {
"node": "./server/providers/military-installations.js"
},
"./server/providers/regional": {
"node": "./server/providers/regional.js"
},
"./server/providers/openai": {
"node": "./server/providers/openai.js"
},
"./server/standalone/key-setup": {
"node": "./server/standalone/key-setup.js"
},
"./search": "./src/search/index.js",
"./ui/panels": "./src/ui/panelDisclosure.js",
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
"./ui/layout": "./src/ui/panelRails.js",
"./ui/input": "./src/ui/visualInput.js",
"./ui/display": "./src/ui/displayControls.js",
"./ui/effects": "./src/ui/effects.js",
"./ui/effects/bloom": "./src/bloom.js",
"./ui/maps": "./src/ui/mapSource.js",
"./ui/layers": "./src/ui/layers.js",
"./ui/layers/feedback": "./src/loadingFeedback.js",
"./ui/location": "./src/ui/location.js",
"./ui/radio": "./src/ui/radio.js",
"./ui/cctv": "./src/ui/cctv.js",
"./ui/context": "./src/ui/context.js",
"./ui/context/policy": "./src/contextModePolicy.js",
"./ui/cockpit": "./src/ui/cockpit.js",
"./ui/cockpit/math": "./src/cockpitMath.js",
"./ui/cockpit/layout": "./src/cockpitUtilityLayout.js",
"./ui/cockpit/vision": "./src/cockpitVisionPolicy.js",
"./ui/scenes": "./src/ui/scenes.js",
"./ui/shell": "./src/ui/applicationShell.js",
"./ui/styles": "./src/ui/styles.js",
"./scenes": "./src/scenes/director.js",
"./sources/live": "./src/sources/live/index.js",
"./layers/flights": "./src/layers/flights/index.js",
"./layers/military": "./src/layers/military/index.js",
"./aircraft": "./src/layers/aircraft/index.js",
"./layers/vessels": "./src/layers/vessels/index.js",
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
"./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js",
"./layers/launches": "./src/layers/launches/index.js",
"./layers/installations": "./src/layers/installations/index.js",
"./layers/awareness": "./src/layers/awareness/index.js",
"./layers/traffic": "./src/layers/traffic/index.js",
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
"./layers/cctv": "./src/layers/cctv/index.js",
"./layers/alpr": "./src/layers/alpr/index.js",
"./layers/radio": "./src/layers/radio/index.js",
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
"./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js",
"./geography/natural-earth": "./src/data/naturalEarthRegions.js",
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
"./maps/controller": "./src/maps/controller.js",
"./maps/catalog": "./src/maps/catalog.js",
"./maps/imagery": "./src/maps/imagery.js",
"./maps/terrain": "./src/maps/terrain.js",
"./maps/3d": "./src/maps/google3d.js",
"./maps/defaults": "./src/maps/defaultSources.js"
}
}
+2
View File
@@ -9,7 +9,9 @@
# Do not enter credentials in Pinokio 8.0.40's native Configure panel: that
# release saves this nested layout to the wrong path and logs submitted values.
# Browser: 3D tiles and geocoding; server: Places and Street View.
# GOOGLE_MAPS_API_KEY=
# GOOGLE_MAPS_SERVER_API_KEY=
# CESIUM_ION_TOKEN=
# OPENAI_API_KEY=
# AISSTREAM_API_KEY=
+1
View File
@@ -16,6 +16,7 @@ module.exports = {
// child also reads the raw app ENVIRONMENT file because Pinokio removes
// blank fields before constructing this merged template environment.
env: {
GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}',
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
+1
View File
@@ -8,6 +8,7 @@ module.exports = {
env: {
HOST: '127.0.0.1',
PORT: '{{port}}',
GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}',
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
+1
View File
@@ -7,6 +7,7 @@ module.exports = {
// Update reuses the install doctor. The child re-reads raw app
// ENVIRONMENT so blank fields override Pinokio-global values too.
env: {
GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}',
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
+146
View File
@@ -0,0 +1,146 @@
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { build, normalizePath } from 'vite';
/** Build each browser or Node export group and reject imports outside its declared ownership. */
export async function checkPackageBoundaries(root) {
root = await realpath(root);
const readJson = async (name) =>
JSON.parse(await readFile(path.join(root, name), 'utf8'));
const pkg = await readJson('package.json');
const groups = await readJson('scripts/package-boundaries.json');
const declaredExports = Object.keys(pkg.exports || {}).sort();
const classifiedExports = Object.values(groups)
.flatMap((group) => group.exports)
.sort();
if (
!declaredExports.length ||
JSON.stringify(declaredExports) !== JSON.stringify(classifiedExports)
) {
throw new Error(
'Every package export must belong to exactly one boundary group',
);
}
const reports = [];
for (const [name, group] of Object.entries(groups)) {
const node = group.runtime === 'node';
if (group.runtime && !['browser', 'node'].includes(group.runtime)) {
throw new Error(`Invalid boundary runtime: ${name}`);
}
if (
!Array.isArray(group.modules) ||
!group.modules.length ||
!Array.isArray(group.external)
) {
throw new Error(`Invalid package boundary: ${name}`);
}
const allowed = new Set();
for (const module of group.modules) {
if (
typeof module !== 'string' ||
path.isAbsolute(module) ||
module.includes('\\') ||
module.split('/').includes('..')
) {
throw new Error(`Boundary modules must be repository paths: ${name}`);
}
const resolved = await realpath(path.join(root, module));
const relative = path.relative(root, resolved);
if (
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new Error(`Boundary module escapes repository: ${name}`);
}
allowed.add(normalizePath(resolved));
}
for (const external of group.external) {
if (
!Object.hasOwn(pkg.dependencies || {}, external) &&
!Object.hasOwn(pkg.peerDependencies || {}, external) &&
!(node && Object.hasOwn(pkg.devDependencies || {}, external))
) {
throw new Error(
`Boundary external must be a declared dependency for its runtime: ${external}`,
);
}
}
const input = group.exports.map((key) => {
const declaration = pkg.exports[key];
const target = node ? declaration?.node : declaration;
if (
node &&
(typeof declaration !== 'object' ||
Object.keys(declaration).join() !== 'node')
) {
throw new Error(`Node export must have only a node condition: ${key}`);
}
if (
typeof target !== 'string' ||
!target.startsWith('./') ||
!allowed.has(normalizePath(path.resolve(root, target)))
) {
throw new Error(`Export must point to an owned module: ${key}`);
}
return path.resolve(root, target);
});
const seen = new Set();
await build({
root,
configFile: false,
envFile: false,
publicDir: false,
ssr: node ? { noExternal: true } : undefined,
logLevel: 'silent',
plugins: [
{
name: 'check-package-ownership',
moduleParsed(info) {
if (!allowed.has(info.id)) {
throw new Error(
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
);
}
seen.add(info.id);
},
},
],
build: {
ssr: node,
lib: { entry: input, formats: ['es'] },
write: false,
minify: false,
assetsInlineLimit: 0,
rollupOptions: {
input,
external: group.external,
// Unused imports must still obey ownership; tree shaking is not a boundary.
treeshake: false,
preserveEntrySignatures: 'strict',
},
},
});
reports.push({ name, exports: input.length, modules: seen.size });
}
return reports;
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) {
try {
const reports = await checkPackageBoundaries(
fileURLToPath(new URL('../', import.meta.url)),
);
for (const report of reports)
console.log(
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`,
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+7 -208
View File
@@ -2,213 +2,12 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PORT="${PORT:-4173}"
HOST="${HOST:-0.0.0.0}"
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
# A lighter CCTV testing preset; startup, credentials, and LAN warnings belong
# to the normal launcher. Explicit environment overrides remain supported.
export CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
export CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
export CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
export CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
if ! command -v npm >/dev/null 2>&1; then
echo "error: npm not found"
exit 1
fi
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
GOOGLE_MAPS_API_KEY_SOURCE=""
if command -v security >/dev/null 2>&1; then
for acct in "api-key" "default" "key"; do
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
break
fi
done
fi
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
GOOGLE_MAPS_API_KEY_SOURCE="env"
else
GOOGLE_MAPS_API_KEY=""
fi
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
echo "error: Google Maps API key missing."
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
exit 1
fi
read_keychain_secret() {
local service="$1"
local account="$2"
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
}
load_opensky_oauth_from_file() {
local file_path="$1"
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
return
fi
if ! command -v node >/dev/null 2>&1; then
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
return
fi
local parsed
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)"
if [[ "${parsed}" != *$'\t'* ]]; then
return
fi
local parsed_client_id="${parsed%%$'\t'*}"
local parsed_client_secret="${parsed#*$'\t'}"
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
OPENSKY_CLIENT_ID="${parsed_client_id}"
fi
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
fi
}
resolve_opensky_credentials() {
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}"
case "${OPENSKY_AUTH_MODE}" in
basic|oauth|auto|anon) ;;
*)
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
OPENSKY_AUTH_MODE="oauth"
;;
esac
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}"
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}"
OPENSKY_USERNAME="${OPENSKY_USERNAME:-}"
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}"
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
for svc in "opensky-network" "opensky"; do
for acct in "username" "user" "login" "email" "default"; do
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
if [[ -n "${OPENSKY_USERNAME}" ]]; then
break 2
fi
done
done
fi
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
for svc in "opensky-network" "opensky"; do
for acct in "password" "pass" "token" "secret" "default"; do
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
break 2
fi
done
done
fi
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
fi
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
for svc in "opensky-network" "opensky"; do
for acct in "client_id" "client-id" "client" "api-key"; do
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
break 2
fi
done
done
fi
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
for svc in "opensky-network" "opensky"; do
for acct in "client_secret" "client-secret" "secret"; do
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
break 2
fi
done
done
fi
case "${OPENSKY_AUTH_MODE}" in
basic)
OPENSKY_CLIENT_ID=""
OPENSKY_CLIENT_SECRET=""
;;
oauth)
OPENSKY_USERNAME=""
OPENSKY_PASSWORD=""
;;
anon)
OPENSKY_CLIENT_ID=""
OPENSKY_CLIENT_SECRET=""
OPENSKY_USERNAME=""
OPENSKY_PASSWORD=""
;;
esac
}
resolve_opensky_credentials
echo "Starting God's Eye View with CCTV source pack..."
echo "URL: http://localhost:${PORT}/"
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
if [[ -f "${CCTV_SOURCES_FILE}" ]]; then
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE}"
else
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE} (missing, using Austin open-data feed discovery)"
fi
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
else
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
fi
fi
case "${OPENSKY_AUTH_MODE}" in
basic)
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
echo "OpenSky basic auth: configured"
else
echo "OpenSky basic auth: missing credentials"
fi
;;
oauth)
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
echo "OpenSky OAuth: configured"
else
echo "OpenSky OAuth: missing client credentials"
fi
;;
auto)
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
echo "OpenSky auto auth: OAuth configured"
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
echo "OpenSky auto auth: basic configured"
else
echo "OpenSky auto auth: no credentials found"
fi
;;
anon)
echo "OpenSky auth: disabled (anonymous mode)"
;;
esac
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE}" \
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN}" \
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \
OPENSKY_USERNAME="${OPENSKY_USERNAME}" \
OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \
npm run dev -- --host "${HOST}" --port "${PORT}" --force
exec bash "$ROOT_DIR/scripts/dev-fresh.sh"
+42 -6
View File
@@ -11,9 +11,11 @@ PORT="${PORT:-4173}"
HOST="${HOST:-localhost}"
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
# pack; override per-run for lighter/heavier loads. Kill switches:
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
# TfL London JamCams (~870 live upstream), Ontario 511 (~944 live upstream,
# including Kitchener-area highways), and Fintraffic Finland weathercams
# (~2,260 live presets). Caps keep the densest cores per pack; override
# per-run for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='',
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0 and CCTV_FINTRAFFIC_ENABLED=0.
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
# is preserved rather than replaced by the default. Still set-u-safe when unset.
@@ -21,7 +23,24 @@ CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
CCTV_ONTARIO_ENABLED="${CCTV_ONTARIO_ENABLED:-1}"
CCTV_ONTARIO_MAX_SOURCES="${CCTV_ONTARIO_MAX_SOURCES:-1000}"
CCTV_FINTRAFFIC_ENABLED="${CCTV_FINTRAFFIC_ENABLED:-1}"
CCTV_FINTRAFFIC_MAX_SOURCES="${CCTV_FINTRAFFIC_MAX_SOURCES:-300}"
CCTV_DRIVEBC_ENABLED="${CCTV_DRIVEBC_ENABLED:-1}"
CCTV_DRIVEBC_MAX_SOURCES="${CCTV_DRIVEBC_MAX_SOURCES:-250}"
CCTV_TXDOT_ENABLED="${CCTV_TXDOT_ENABLED:-1}"
# Use `-` so an explicit empty string (the documented kill switch) is preserved.
CCTV_TXDOT_DISTRICTS="${CCTV_TXDOT_DISTRICTS-AUS,SAT}"
CCTV_TXDOT_MAX_SOURCES="${CCTV_TXDOT_MAX_SOURCES:-500}"
CCTV_TALLINN_ENABLED="${CCTV_TALLINN_ENABLED:-1}"
CCTV_TALLINN_MAX_SOURCES="${CCTV_TALLINN_MAX_SOURCES:-255}"
CCTV_TARKTEE_ENABLED="${CCTV_TARKTEE_ENABLED:-1}"
CCTV_TARKTEE_MAX_SOURCES="${CCTV_TARKTEE_MAX_SOURCES:-179}"
CCTV_WARENDORF_ENABLED="${CCTV_WARENDORF_ENABLED:-1}"
CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
# Capture which provider credentials genuinely came from the parent shell
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
@@ -30,6 +49,7 @@ CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
# the same value, without misclassifying values that dev-fresh loaded from .env.
KEY_SETUP_EXTERNAL_KEYS=()
[[ -n "${GOOGLE_MAPS_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_API_KEY)
[[ -n "${GOOGLE_MAPS_SERVER_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_SERVER_API_KEY)
[[ -n "${CESIUM_ION_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(CESIUM_ION_TOKEN)
[[ -n "${OPENAI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENAI_API_KEY)
[[ -n "${AISSTREAM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(AISSTREAM_API_KEY)
@@ -225,8 +245,8 @@ if [[ ! -f "src/data/cctv.js" ]]; then
exit 1
fi
if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then
echo "error: CCTV layer not wired in src/main.js"
if ! grep -q "dataManager.register(cctvLayer)" src/standalone/data.js; then
echo "error: CCTV layer not wired in src/standalone/data.js"
exit 1
fi
@@ -363,7 +383,23 @@ put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}"
put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
put_env CCTV_ONTARIO_ENABLED "${CCTV_ONTARIO_ENABLED}"
put_env CCTV_ONTARIO_MAX_SOURCES "${CCTV_ONTARIO_MAX_SOURCES}"
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
put_env CCTV_FINTRAFFIC_ENABLED "${CCTV_FINTRAFFIC_ENABLED}"
put_env CCTV_FINTRAFFIC_MAX_SOURCES "${CCTV_FINTRAFFIC_MAX_SOURCES}"
put_env CCTV_DRIVEBC_ENABLED "${CCTV_DRIVEBC_ENABLED}"
put_env CCTV_DRIVEBC_MAX_SOURCES "${CCTV_DRIVEBC_MAX_SOURCES}"
put_env CCTV_TXDOT_ENABLED "${CCTV_TXDOT_ENABLED}"
put_env CCTV_TXDOT_DISTRICTS "${CCTV_TXDOT_DISTRICTS}"
put_env CCTV_TXDOT_MAX_SOURCES "${CCTV_TXDOT_MAX_SOURCES}"
put_env CCTV_TALLINN_ENABLED "${CCTV_TALLINN_ENABLED}"
put_env CCTV_TALLINN_MAX_SOURCES "${CCTV_TALLINN_MAX_SOURCES}"
put_env CCTV_TARKTEE_ENABLED "${CCTV_TARKTEE_ENABLED}"
put_env CCTV_TARKTEE_MAX_SOURCES "${CCTV_TARKTEE_MAX_SOURCES}"
put_env CCTV_WARENDORF_ENABLED "${CCTV_WARENDORF_ENABLED}"
put_env CCTV_NSW_ENABLED "${CCTV_NSW_ENABLED}"
put_env CCTV_NSW_MAX_SOURCES "${CCTV_NSW_MAX_SOURCES}"
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
+536
View File
@@ -0,0 +1,536 @@
[
".prettierrc.json",
"package.json",
"scripts/format-scope.json",
"scripts/package-boundaries.json",
"scripts/format.mjs",
"scripts/check-package-boundaries.mjs",
"scripts/precompute-cctv-heights.mjs",
"src/tooling/precomputeCctvHeights.test.mjs",
"src/tooling/format.test.mjs",
"src/tooling/packageBoundaries.test.mjs",
"src/data/infrastructure.js",
"src/data/infrastructure.test.mjs",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"docs/CODE-BOUNDARIES.md",
"src/main.js",
"docs/APPLICATION.md",
"scripts/qa-application.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
"src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs",
"src/app/viewer.js",
"src/standalone/application.js",
"src/standalone/controls.js",
"src/standalone/data.js",
"src/standalone/errors.js",
"src/standalone/scene.js",
"src/standalone/startupChrome.js",
"src/standalone/startupChrome.test.mjs",
"src/standalone/tools.js",
"build/vite.js",
"server/standalone/vite.config.js",
"vite.config.js",
"src/tooling/viteBuild.test.mjs",
"server/providers/common/http.js",
"server/providers/common/query.js",
"server/providers/aircraft/opensky.js",
"server/providers/aircraft/adsb-lol.js",
"server/providers/aircraft/enrichment.js",
"server/providers/aircraft/tracks.js",
"server/providers/vessels/ais-store.js",
"server/providers/vessels/ais-live.js",
"server/providers/live.js",
"src/tooling/liveProviders.test.mjs",
"src/data/adsbLolFallback.js",
"src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js",
"server/providers/common/geo.js",
"server/providers/common/rate-limit.js",
"server/providers/places.js",
"server/providers/places/google-key.js",
"server/providers/places/google.js",
"server/providers/places/routes.js",
"src/data/placeProviderPayloads.js",
"src/tooling/placeProviders.test.mjs",
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/space/launch-library.js",
"src/data/spaceProviderRequests.js",
"src/tooling/spaceProviders.test.mjs",
"server/providers/terrain.js",
"server/providers/traffic.js",
"server/providers/firms.js",
"server/providers/gbfs.js",
"src/data/gbfsSource.js",
"src/data/terrainHeightsProxy.js",
"src/data/terrainHeightsProxy.test.mjs",
"src/data/tomtomTiles.js",
"src/data/tomtomTiles.test.mjs",
"src/data/firmsCsv.js",
"src/data/firmsCsv.test.mjs",
"src/tooling/environmentProviders.test.mjs",
"server/providers/cctv.js",
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
"server/providers/cctv/sources.js",
"server/providers/radio.js",
"server/providers/radio/catalog.js",
"server/providers/radio/constants.js",
"server/providers/radio/stations.js",
"server/providers/radio/transport.js",
"src/tooling/mediaProviders.test.mjs",
"server/standalone/api-not-found.js",
"src/tooling/previewServing.test.mjs",
"server/providers/common/request.js",
"server/providers/common/source-root.js",
"server/providers/local.js",
"server/providers/military-installations.js",
"server/providers/military-installations/cache.js",
"server/providers/military-installations/constants.js",
"server/providers/military-installations/query.js",
"server/providers/openai.js",
"server/providers/openai/constants.js",
"server/providers/openai/debug-log.js",
"server/providers/openai/hud-summary.js",
"server/providers/openai/instructions.js",
"server/providers/openai/rate-limit.js",
"server/providers/openai/realtime.js",
"server/providers/openai/tools.js",
"server/providers/overpass.js",
"server/providers/overpass/cache.js",
"server/providers/overpass/constants.js",
"server/providers/overpass/geometry.js",
"server/providers/overpass/query.js",
"server/providers/overpass/transport.js",
"server/providers/regional.js",
"server/providers/regional/briefing.js",
"server/providers/regional/http.js",
"server/providers/regional/news.js",
"server/providers/regional/place.js",
"server/providers/regional/query.js",
"server/providers/regional/weather-effects.js",
"server/providers/regional/weather.js",
"server/standalone/key-setup.js",
"src/tooling/localServices.test.mjs",
"src/search/index.js",
"src/search/placeSearch.js",
"src/search/google.js",
"src/keylessGeocoder.js",
"src/standalone/placeSearch.js",
"src/ui/panelDisclosure.js",
"src/ui/surfaceKeyboard.js",
"src/ui/surfaceKeyboard.test.mjs",
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs",
"src/panelStackLayout.js",
"src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js",
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"src/ui/displayControls.js",
"src/ui/displayControls.test.mjs",
"src/ui/effects.js",
"src/ui/visualEffects.js",
"src/ui/visualPresets.js",
"src/ui/visualEffects.test.mjs",
"src/bloom.js",
"src/styles/retro.js",
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
"src/mapStackChips.js",
"scripts/qa-map-source-controls.mjs",
"src/ui/layers.js",
"src/ui/layerPanel.js",
"src/ui/clearLayersControl.js",
"scripts/qa-layer-panel.mjs",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/ui/location.js",
"src/ui/locationControls.js",
"src/ui/locationSearch.js",
"src/locationStatus.js",
"src/ui/locationControls.test.mjs",
"src/ui/locationSearch.test.mjs",
"scripts/qa-location-controls.mjs",
"src/ui/radio.js",
"src/ui/radioControls.js",
"src/ui/radioBindings.js",
"src/ui/radioPresentation.js",
"src/ui/radioTunerModel.js",
"src/ui/radioControls.test.mjs",
"scripts/qa-ui-disposal.mjs",
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/cctvControls.js",
"src/ui/cctvControls.test.mjs",
"src/ui/cctvFrames.js",
"src/ui/cctvPresentation.js",
"scripts/qa-camera-controls.mjs",
"src/ui/context.js",
"src/ui/contextActions.js",
"src/ui/contextBindings.js",
"src/ui/contextControls.js",
"src/ui/contextControls.test.mjs",
"src/ui/contextLayerChanges.js",
"src/ui/contextPresentation.js",
"src/ui/contextSession.js",
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitController.test.mjs",
"src/ui/cockpitDisplayPortal.js",
"src/ui/cockpitDisplayPortal.test.mjs",
"src/ui/cockpitInput.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js",
"src/ui/cockpitTrackingController.js",
"src/cockpitMath.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/sceneControls.test.mjs",
"scripts/qa-scene-controls.mjs",
"src/ui.js",
"src/standalone/ui.js",
"src/ui/applicationShell.js",
"src/ui/panelLayoutController.js",
"src/ui/panelPositionControls.js",
"src/ui/recordingControls.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/uiLifetime.js",
"src/ui/styles.js",
"src/ui/shellLifecycle.test.mjs",
"src/testSupport/readStylesheet.mjs",
"src/splitFlap.js",
"style.css",
"src/ui/styles/overlays.css",
"src/ui/styles/command-dock-trays.css",
"src/ui/styles/location.css",
"src/ui/styles/recording.css",
"src/ui/styles/status.css",
"src/ui/styles/layers.css",
"src/ui/styles/controls.css",
"src/ui/styles/cctv.css",
"src/ui/styles/foundation.css",
"src/ui/styles/first-run.css",
"src/ui/styles/scenes.css",
"src/ui/styles/command-dock.css",
"src/ui/styles/command-dock-sliding.css",
"src/ui/styles/provider-settings.css",
"src/ui/styles/radio.css",
"src/ui/styles/responsive.css",
"src/ui/styles/voice-cost.css",
"src/ui/styles/cockpit.css",
"src/ui/styles/command-dock-compact.css",
"src/app/stateChannel.js",
"src/app/stateChannel.test.mjs",
"src/sources/live/aircraft.js",
"src/sources/live/contract.js",
"src/sources/live/contract.test.mjs",
"src/sources/live/index.js",
"src/sources/live/standalone.js",
"src/sources/live/vessels.js",
"src/data/flights.js",
"src/testSupport/readLayerSource.mjs",
"src/layers/flights/lifecycle.js",
"src/layers/flights/motion.js",
"src/layers/flights/controller.js",
"src/layers/flights/state.js",
"src/layers/flights/policy.js",
"src/layers/flights/tracking.js",
"src/layers/flights/rendering.js",
"src/layers/flights/testing.js",
"src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/queries.js",
"src/layers/flights/index.js",
"src/layers/flights/enrichment.js",
"src/layers/flights/ownership.test.mjs",
"src/data/militaryFlights.js",
"src/data/militaryRegistry.js",
"src/layers/military/lifecycle.js",
"src/layers/military/motion.js",
"src/layers/military/controller.js",
"src/layers/military/state.js",
"src/layers/military/policy.js",
"src/layers/military/tracking.js",
"src/layers/military/rendering.js",
"src/layers/military/testing.js",
"src/layers/military/ingestion.js",
"src/layers/military/queries.js",
"src/layers/military/index.js",
"src/layers/aircraft/index.js",
"src/layers/aircraft/classification.js",
"src/layers/military/ownership.test.mjs",
"src/layers/aircraft/classification.test.mjs",
"src/data/aisLiveVessels.js",
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs",
"src/data/earthquakes.js",
"src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/ownership.test.mjs",
"src/data/firmsHeatmap.js",
"src/data/fireAnchors.js",
"src/layers/firms/anchors.js",
"src/layers/firms/cards.js",
"src/layers/firms/index.js",
"src/layers/firms/ingestion.js",
"src/layers/firms/lifecycle.js",
"src/layers/firms/model.js",
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
"src/layers/firms/viewport.js",
"src/layers/firms/source.test.mjs",
"src/layers/satellites/lifecycle.js",
"src/layers/satellites/interaction.js",
"src/layers/satellites/controls.js",
"src/layers/satellites/source.js",
"src/layers/satellites/labels.js",
"src/layers/satellites/catalog.js",
"src/layers/satellites/state.js",
"src/layers/satellites/policy.js",
"src/layers/satellites/tracking.js",
"src/layers/satellites/rendering.js",
"src/layers/satellites/testing.js",
"src/layers/satellites/ingestion.js",
"src/layers/satellites/index.js",
"src/layers/satellites/orbits.js",
"src/layers/launches/lifecycle.js",
"src/layers/launches/controls.js",
"src/layers/launches/panel.js",
"src/layers/launches/source.js",
"src/layers/launches/policyHelpers.js",
"src/layers/launches/replay.js",
"src/layers/launches/launchPad.js",
"src/layers/launches/state.js",
"src/layers/launches/policy.js",
"src/layers/launches/rendering.js",
"src/layers/launches/testing.js",
"src/layers/launches/ingestion.js",
"src/layers/launches/overlays.js",
"src/layers/launches/orbitRendering.js",
"src/layers/launches/index.js",
"src/layers/launches/paths.js",
"src/layers/launches/model.js",
"src/layers/launches/selection.js",
"src/layers/launches/camera.js",
"src/data/satellites.js",
"src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs",
"src/layers/launches/source.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",
"src/layers/awareness/controls.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/state.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/testing.js",
"src/layers/awareness/ingestion.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/navigation.js",
"src/layers/awareness/index.js",
"src/layers/awareness/model.js",
"src/layers/awareness/history.js",
"src/layers/awareness/subject.js",
"src/data/militaryInstallations.js",
"src/data/militaryAwareness.js",
"src/layers/installations/source.test.mjs",
"src/layers/traffic/flow.js",
"src/layers/traffic/lifecycle.js",
"src/layers/traffic/viewport.js",
"src/layers/traffic/controls.js",
"src/layers/traffic/source.js",
"src/layers/traffic/state.js",
"src/layers/traffic/policy.js",
"src/layers/traffic/rendering.js",
"src/layers/traffic/ingestion.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/animation.js",
"src/layers/traffic/index.js",
"src/layers/traffic/flowDecode.js",
"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/data/flowTiles.js",
"src/layers/traffic/source.test.mjs",
"src/layers/bikeshare/source.test.mjs",
"scripts/traffic-fixtures.mjs",
"src/layers/traffic/navigation.test.mjs",
"scripts/qa-traffic-navigation.mjs",
"src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js",
"src/layers/cctv/source.js",
"src/layers/cctv/catalog.js",
"src/layers/cctv/state.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/projection.js",
"src/layers/cctv/cards.js",
"src/layers/cctv/rendering.js",
"src/layers/cctv/testing.js",
"src/layers/cctv/ingestion.js",
"src/layers/cctv/hover.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/index.js",
"src/layers/cctv/model.js",
"src/layers/cctv/geometryQueue.js",
"src/layers/cctv/selection.js",
"src/layers/cctv/calibration.js",
"src/layers/cctv/frames.js",
"src/layers/cctv/presentation.js",
"src/layers/cctv/ground.js",
"src/layers/cctv/health.js",
"src/data/cctv.js",
"src/data/cctvCatalogCap.test.mjs",
"src/data/cctvDisplayCode.test.mjs",
"src/data/cctvFootprint.js",
"src/data/cctvGroundHeights.test.mjs",
"src/data/cctvFootprint.test.mjs",
"src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
"src/data/alprCameras.js",
"src/data/alprCameras.test.mjs",
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs",
"src/layers/radio/catalogModel.js",
"src/layers/radio/categories.js",
"src/layers/radio/clustering.js",
"src/layers/radio/controls.js",
"src/layers/radio/index.js",
"src/layers/radio/ingestion.js",
"src/layers/radio/interaction.js",
"src/layers/radio/labels.js",
"src/layers/radio/lifecycle.js",
"src/layers/radio/model.js",
"src/layers/radio/navigation.js",
"src/layers/radio/playback.js",
"src/layers/radio/policy.js",
"src/layers/radio/presentation.js",
"src/layers/radio/queries.js",
"src/layers/radio/rendering.js",
"src/layers/radio/selection.js",
"src/layers/radio/source.js",
"src/layers/radio/state.js",
"src/layers/radio/tuning.js",
"src/layers/radio/tuningNoise.js",
"src/layers/radio/volume.js",
"src/data/radio.js",
"src/layers/radio/source.test.mjs",
"src/layers/submarineCables/bundledSource.js",
"src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js",
"src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js",
"src/layers/submarineCables/lifecycle.js",
"src/layers/submarineCables/overlay.js",
"src/layers/submarineCables/policy.js",
"src/layers/submarineCables/rendering.js",
"src/layers/submarineCables/state.js",
"src/layers/submarineCables/surface.js",
"src/data/telegeographySubmarineCables.js",
"src/data/telegeographySubmarineCables.test.mjs",
"src/data/naturalEarthRegions.js",
"src/data/neighborhoodPolygons.js",
"src/layers/submarineCables/bundledSource.test.mjs",
"src/maps/availability.js",
"src/maps/catalog.js",
"src/maps/controller.js",
"src/maps/controller.test.mjs",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
"src/maps/imagery.js",
"src/maps/registry.js",
"src/maps/terrain.js",
"src/mapStackController.js",
"src/mapStartup.js",
"src/mapStackChips.test.mjs",
"src/mapStackUnavailableReason.test.mjs",
"src/mapStartup.test.mjs",
"src/maps/sourceFactories.test.mjs"
]
+101
View File
@@ -0,0 +1,101 @@
import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as prettier from 'prettier';
/** Check or format the explicitly adopted files, without scanning other source. */
export async function formatAdoptedFiles(root, mode) {
if (!['--check', '--write'].includes(mode)) {
throw new Error('Usage: node scripts/format.mjs --check|--write');
}
root = await realpath(root);
const scope = JSON.parse(
await readFile(path.join(root, 'scripts/format-scope.json'), 'utf8'),
);
if (
!Array.isArray(scope) ||
!scope.length ||
new Set(scope).size !== scope.length
) {
throw new Error(
'Formatting scope must be a nonempty list of unique file paths',
);
}
// Validate the whole scope before any write, including resolved symlink paths.
const files = [];
for (const name of scope) {
if (
typeof name !== 'string' ||
!name ||
name.includes('\\') ||
path.isAbsolute(name) ||
name.split('/').includes('..')
) {
throw new Error(
'Formatting scope entries must be paths inside the repository',
);
}
const file = path.join(root, name);
const relative = path.relative(root, await realpath(file));
if (
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
!(await stat(file)).isFile()
) {
throw new Error(
`Formatting scope must contain repository files: ${name}`,
);
}
const info = await prettier.getFileInfo(file, {
ignorePath: path.join(root, '.prettierignore'),
});
if (info.ignored || !info.inferredParser) {
throw new Error(
`Formatting scope contains an ignored or unsupported file: ${name}`,
);
}
const options = { ...(await prettier.resolveConfig(file)), filepath: file };
const source = await readFile(file, 'utf8');
files.push({
name,
file,
source,
formatted: await prettier.format(source, options),
});
}
const changed = files.filter(({ source, formatted }) => source !== formatted);
if (mode === '--write') {
for (const { file, formatted } of changed) await writeFile(file, formatted);
}
return { count: files.length, changed: changed.map(({ name }) => name) };
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) {
try {
if (process.argv.length !== 3)
throw new Error('Usage: node scripts/format.mjs --check|--write');
const mode = process.argv[2];
const result = await formatAdoptedFiles(
fileURLToPath(new URL('../', import.meta.url)),
mode,
);
if (mode === '--check' && result.changed.length) {
for (const name of result.changed)
console.error(`Needs formatting: ${name}`);
process.exitCode = 1;
} else {
console.log(
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`,
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+5
View File
@@ -0,0 +1,5 @@
/** Resolve server credentials after applying environment overrides per key. */
export function resolveGoogleServerKey(environment = {}, defaults = {}) {
const value = (name) => String(environment[name] ?? defaults[name] ?? '').trim();
return value('GOOGLE_MAPS_SERVER_API_KEY') || value('GOOGLE_MAPS_API_KEY');
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -8,6 +8,7 @@ const DEFAULT_ENVIRONMENT_FILE = path.join(ROOT, 'pinokio', 'ENVIRONMENT');
export const PINOKIO_CONFIG_FIELDS = Object.freeze([
'GOOGLE_MAPS_API_KEY',
'GOOGLE_MAPS_SERVER_API_KEY',
'CESIUM_ION_TOKEN',
'OPENAI_API_KEY',
'AISSTREAM_API_KEY',
+487
View File
@@ -0,0 +1,487 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
planeSupportPoints,
poseHash,
SUPPORT_KEYS,
} from '../src/data/cctvFootprint.js';
const ROOT = fileURLToPath(new URL('../', import.meta.url));
const SAMPLE_KEYS = ['mount', ...SUPPORT_KEYS];
const BATCH_SIZES = [16, 4, 1];
const DEADLINE_MS = 90_000;
/** Extract the served nominal pose (the values the catalog ships). */
export function nominalPose(source) {
const pose = Object.fromEntries(
[
'lat',
'lon',
'headingDeg',
'pitchDeg',
'fovDeg',
'rangeM',
'mountHeightM',
].map((key) => {
if (typeof source[key] !== 'number' || !Number.isFinite(source[key])) {
throw new Error(`Camera ${source.id}: invalid ${key}`);
}
return [key, source[key]];
}),
);
if (Math.abs(pose.lat) > 90 || Math.abs(pose.lon) > 180) {
throw new Error(`Camera ${source.id}: invalid coordinates`);
}
// Hashed exactly as served: the server join (groundHeights.js) and the
// client (camera.servedPose) hash the same raw values, so no clamp here.
return pose;
}
/** Return a roughly 2 km grid key, with longitude width fixed per row. */
export function cellKey({ lat, lon }) {
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
throw new Error('Invalid cell coordinates');
}
const row = Math.min(8999, Math.floor((lat + 90) / 0.02));
const rowLat = -90 + (row + 0.5) * 0.02;
const lonWidth = 0.02 / Math.cos((rowLat * Math.PI) / 180);
const col = Math.floor(((lon === 180 ? -180 : lon) + 180) / lonWidth);
return `${row},${col}`;
}
/** Split an ordered array into bounded batches without mutating it. */
export function batches(items, size = 16) {
if (!Number.isSafeInteger(size) || size < 1)
throw new Error('Invalid batch size');
const result = [];
for (let i = 0; i < items.length; i += size)
result.push(items.slice(i, i + size));
return result;
}
/** Group cameras by cell and order cells numerically by row, then column. */
export function planCells(cameras) {
const cells = new Map();
for (const camera of cameras) {
const key = cellKey(camera.pose);
if (!cells.has(key)) cells.set(key, { key, cameras: [] });
cells.get(key).cameras.push(camera);
}
return [...cells.values()].sort((a, b) => {
const [ar, ac] = a.key.split(',').map(Number);
const [br, bc] = b.key.split(',').map(Number);
return ar - br || ac - bc;
});
}
/** Preserve ellipsoidal metres; missing, non-finite and implausible values are null. */
export function plausibleHeight(height) {
return Number.isFinite(height) && height >= -150 ? height : null;
}
/**
* Only a complete, successful entry for exactly the current nominal pose is
* reusable; an entry with missing supports is resampled (valid heights are
* kept by mergeCameraSamples).
*/
export function needsSampling(entry, pose) {
return (
entry?.status !== 'ok' ||
entry.poseHash !== poseHash(pose) ||
(Array.isArray(entry.misses) && entry.misses.length > 0)
);
}
/** Merge retry samples without losing valid heights; a missing mount stores no heights. */
export function mergeCameraSamples(
pose,
heights,
previous,
attempts,
sampledAt,
) {
const hash = poseHash(pose);
const prior = previous?.poseHash === hash ? previous : null;
const values = SAMPLE_KEYS.map(
(key, i) =>
plausibleHeight(heights[i]) ??
plausibleHeight(
key === 'mount' ? prior?.mountGroundM : prior?.supports?.[key],
),
);
const entry = {
poseHash: hash,
status: values[0] === null ? 'miss' : 'ok',
misses: SAMPLE_KEYS.filter((_, i) => values[i] === null),
sampledAt,
attempts,
};
if (entry.status === 'ok') {
entry.mountGroundM = values[0];
entry.supports = Object.fromEntries(
SUPPORT_KEYS.map((key, i) => [key, values[i + 1]]),
);
}
return entry;
}
/** Build a sidecar while retaining entries outside this run's selection. */
export function mergeSidecar(existing, updates, generatedAt) {
const cameras = { ...existing?.cameras, ...updates };
return {
schemaVersion: 1,
provider: 'google-3d-tiles',
heightReference: 'WGS84-ellipsoid',
generatedAt,
cameras: Object.fromEntries(
Object.keys(cameras)
.sort()
.map((id) => [id, cameras[id]]),
),
};
}
/** Parse pilot filters; the limit applies after kinds, before resume filtering. */
export function parseArgs(argv) {
const options = { limit: Infinity, kinds: null, dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run') options.dryRun = true;
else if (arg === '--limit') {
const value = argv[++i];
if (!/^\d+$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) {
throw new Error('--limit requires a non-negative integer');
}
options.limit = Number(value);
} else if (arg === '--kinds') {
const value = argv[++i];
if (
!value ||
value.startsWith('--') ||
value.split(',').some((kind) => !kind.trim())
) {
throw new Error('--kinds requires comma-separated sourceKinds');
}
options.kinds = value.split(',').map((kind) => kind.trim());
} else throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
/** Use the shared footprint's mount and nine supports in the sampling wire order. */
export function samplingPoints(pose) {
const footprint = planeSupportPoints(pose);
return SAMPLE_KEYS.map((key) => {
const point = key === 'mount' ? footprint.mount : footprint.supports[key];
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) {
throw new Error(
`Shared planeSupportPoints returned invalid coordinates for ${key}`,
);
}
return { lat: point.lat, lon: point.lon };
});
}
/** Sample one cell serially, retrying failed cameras at most three times (16/4/1). */
export async function sampleCell(
cameras,
sampler,
now = () => new Date().toISOString(),
existing = null,
) {
// Seed with the prior entries so a resumed camera keeps every valid height
// it already has (mergeCameraSamples only replaces misses).
const entries = Object.create(null);
for (const camera of cameras) {
if (existing?.[camera.id]) entries[camera.id] = existing[camera.id];
}
let pending = cameras;
for (const [index, size] of BATCH_SIZES.entries()) {
const retry = [];
for (const batch of batches(pending, size)) {
let samples;
try {
samples = await sampler.sample(batch);
} catch (error) {
sampler.warn?.(
`Attempt ${index + 1}, ${batch.length} cameras: ${error.message}`,
);
// A timed-out evaluate can still be sampling: destroy its page before retrying.
await sampler.recycle();
samples = batch.map(() => []);
}
const sampledAt = now();
for (let i = 0; i < batch.length; i += 1) {
const camera = batch[i];
const entry = mergeCameraSamples(
camera.pose,
samples[i] ?? [],
entries[camera.id],
index + 1,
sampledAt,
);
entries[camera.id] = entry;
if (entry.misses.length) retry.push(camera);
}
}
pending = retry;
if (!pending.length) break;
}
return entries;
}
/** Bound a sampling operation; callers must recycle its page after rejection. */
export async function withDeadline(operation, timeoutMs = DEADLINE_MS) {
let timer;
try {
return await Promise.race([
operation,
new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Height sampling timed out after ${timeoutMs / 1000} s`,
),
),
timeoutMs,
);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function openPage(browser, base) {
const page = await browser.newPage();
try {
await page.goto(
`${base}/#v=2&lat=30&lon=-95&alt=17000000&heading=0&pitch=-90&roll=0`,
);
await page.waitForFunction(() => !!window.__godsEyeView?.viewer);
return page;
} catch (error) {
await page.close();
throw error;
}
}
async function loadSidecar(output) {
let existing;
try {
existing = JSON.parse(await readFile(output, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
if (
existing?.schemaVersion !== 1 ||
existing.provider !== 'google-3d-tiles' ||
existing.heightReference !== 'WGS84-ellipsoid' ||
!existing.cameras ||
typeof existing.cameras !== 'object' ||
Array.isArray(existing.cameras)
) {
throw new Error(`Incompatible sidecar: ${output}`);
}
return existing;
}
async function writeSidecar(output, sidecar) {
await mkdir(path.dirname(output), { recursive: true });
const temporary = `${output}.${process.pid}.tmp`;
try {
await writeFile(temporary, `${JSON.stringify(sidecar, null, 2)}\n`);
await rename(temporary, output);
} finally {
await rm(temporary, { force: true });
}
}
function counts(cameras, entries) {
const ok = cameras.filter(
(camera) => entries[camera.id]?.status === 'ok',
).length;
return { ok, miss: cameras.length - ok };
}
async function main() {
const started = Date.now();
const options = parseArgs(process.argv.slice(2));
const base = (process.env.GEV_BASE || 'http://localhost:4173').replace(
/\/+$/,
'',
);
const output = path.resolve(
ROOT,
process.env.OUT ||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
);
let sidecar = await loadSidecar(output);
const response = await fetch(`${base}/api/cctv/sources`, {
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
const catalog = await response.json();
if (!Array.isArray(catalog.sources))
throw new Error('Catalog must contain sources[]');
const ids = new Set();
const selected = catalog.sources
.filter(
(source) => !options.kinds || options.kinds.includes(source.sourceKind),
)
.slice(0, options.limit)
.flatMap((source) => {
if (typeof source.id !== 'string' || !source.id || ids.has(source.id)) {
throw new Error('Catalog has an invalid or duplicate camera id');
}
ids.add(source.id);
let pose;
try {
pose = nominalPose(source);
} catch (error) {
// One camera with a malformed pose must not abort the whole run.
console.warn(`skip ${source.id}: ${error.message}`);
return [];
}
return [
{
id: source.id,
sourceKind: source.sourceKind || 'unknown',
pose,
},
];
});
const pending = selected.filter((camera) =>
needsSampling(sidecar?.cameras?.[camera.id], camera.pose),
);
const cells = planCells(pending);
console.log(
`${selected.length} cameras selected, ${selected.length - pending.length} resumed, ${pending.length} to sample in ${cells.length} cells`,
);
if (options.dryRun) {
for (const cell of cells) {
const sizes = batches(cell.cameras).map(
(batch) => `${batch.length} cameras/${batch.length * 10} points`,
);
console.log(
`cell ${cell.key}: ${cell.cameras.length} cameras; batches: ${sizes.join(', ')}`,
);
}
console.log(
'Retries: failed cameras in batches of 4, then singly; 3 attempts, 90 s per call.',
);
return;
}
// Validate shared geometry before starting an expensive browser session.
for (const camera of pending) camera.points = samplingPoints(camera.pose);
let browser;
try {
if (pending.length) {
const { default: puppeteer } = await import('puppeteer');
browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
protocolTimeout: 600000,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1200,800',
],
});
let page = await openPage(browser, base);
const sampler = {
warn: (message) => console.error(message),
recycle: async () => {
await page.close();
page = await openPage(browser, base);
},
sample: (batch) =>
withDeadline(
page.evaluate(async (cameras) => {
const gev = window.__godsEyeView;
const scene = gev.viewer.scene;
const Cartesian3 = gev.viewer.camera.position.constructor;
const Cartographic =
gev.viewer.camera.positionCartographic.constructor;
const lat =
cameras.reduce((sum, camera) => sum + camera.pose.lat, 0) /
cameras.length;
const lon =
cameras.reduce((sum, camera) => sum + camera.pose.lon, 0) /
cameras.length;
gev.viewer.camera.setView({
destination: Cartesian3.fromDegrees(lon, lat, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const cartographics = cameras.flatMap((camera) =>
camera.points.map((point) =>
Cartographic.fromDegrees(point.lon, point.lat),
),
);
const sampled =
await scene.sampleHeightMostDetailed(cartographics);
return cameras.map((_, i) =>
sampled
.slice(i * 10, (i + 1) * 10)
.map((point) =>
Number.isFinite(point.height) ? point.height : null,
),
);
}, batch),
),
};
for (const cell of cells) {
const cellStarted = Date.now();
const entries = await sampleCell(
cell.cameras,
sampler,
undefined,
sidecar?.cameras,
);
sidecar = mergeSidecar(sidecar, entries, new Date().toISOString());
await writeSidecar(output, sidecar);
const tally = counts(cell.cameras, entries);
console.log(
`cell ${cell.key}: ${cell.cameras.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - cellStarted) / 1000).toFixed(1)} s`,
);
}
}
} finally {
if (browser) await browser.close();
}
const entries = sidecar?.cameras || {};
const tally = counts(selected, entries);
console.log(
`Total: ${selected.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - started) / 1000).toFixed(1)} s`,
);
for (const kind of [
...new Set(selected.map((camera) => camera.sourceKind)),
].sort()) {
const tally = counts(
selected.filter((camera) => camera.sourceKind === kind),
entries,
);
console.log(` ${kind}: ${tally.ok} ok/${tally.miss} miss`);
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/** Prove standalone startup and terminal resource ownership in a real browser. */
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
try {
const page = await browser.newPage();
const errors = [];
page.on('console', (message) => {
if (
message.type() === 'warn' &&
/^\[Data\].*(?:destroy|disable) error:/.test(message.text())
) {
console.log(message.text());
}
});
page.on('pageerror', (error) => errors.push(error.stack || error.message));
await page.goto(`${url}/?welcome=1`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
timeout: 60_000,
});
const before = await page.evaluate(async () => {
const entry = [...document.scripts].find((script) =>
/\/src\/main\.js(?:\?|$)/.test(script.src),
);
const { application } = await import(entry.src);
window.__qaApplication = application;
const app = window.__godsEyeView;
window.__qaComponents = app;
await application.start();
await app.styleManager.initialRestorePromise;
const enabled = await app.dataManager.setEnabled('local-datacenters', true);
const annotation = await app.annotations.annotate([
{
type: 'pin',
longitude: -97.74,
latitude: 30.27,
label: 'Lifecycle check',
},
]);
return {
status: application.getState().status,
enabled,
layers: app.dataManager.layers.size,
annotationCount: annotation.drawn,
viewerAlive: !app.viewer.isDestroyed(),
credits: Boolean(document.querySelector('#cesium-credits')),
};
});
assert.equal(before.status, 'ready');
assert.equal(before.enabled, true);
assert.ok(before.layers >= 16);
assert.equal(before.annotationCount, 1);
assert.equal(before.viewerAlive, true);
assert.equal(before.credits, true);
console.log(
'PASS: startup, data registration, annotations and visible attribution',
);
const after = await page.evaluate(async () => {
const application = window.__qaApplication;
const app = window.__qaComponents;
const first = application.destroy();
const samePromise = first === application.destroy();
await first.catch((error) => {
const describe = (failure) =>
failure.errors?.map(describe).join('; ') || failure.message;
throw new Error(describe(error));
});
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('resize'));
app.requestRender('after-destroy');
await new Promise((resolve) => setTimeout(resolve, 1400));
return {
status: application.getState().status,
samePromise,
viewerDestroyed: app.viewer.isDestroyed(),
layers: app.dataManager.layers.size,
annotations: app.annotations.count(),
governor: app.getRenderGovernorDiagnostics(),
handlesRemoved:
!window.__godsEyeView &&
!window.__gevVoiceCommands &&
!window.__gevAnnotations,
creditsRemoved: !document.querySelector('#cesium-credits'),
welcomeHidden: !document.querySelector('#first-run-launcher.visible'),
settingsRemoved: !document.querySelector('#key-setup'),
};
});
assert.equal(after.status, 'destroyed');
assert.equal(after.samePromise, true);
assert.equal(after.viewerDestroyed, true);
assert.equal(after.layers, 0);
assert.equal(after.annotations, 0);
assert.equal(after.governor.installed, false);
assert.deepEqual(after.governor.holds, []);
assert.equal(after.handlesRemoved, true);
assert.equal(after.creditsRemoved, true);
assert.equal(after.welcomeHidden, true);
assert.equal(after.settingsRemoved, true);
assert.deepEqual(errors, []);
console.log(
'PASS: terminal teardown releases runtime owners without late browser errors',
);
} finally {
await browser.close();
}
+1 -1
View File
@@ -34,7 +34,7 @@ mkdirSync(SHOT_DIR, { recursive: true });
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env node
/** Camera-panel browser acceptance with controlled sources, images and terrain. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
const sources = [
{
id: 'qa-camera-a',
name: 'QA Camera A',
lat: 30.2747,
lon: -97.7403,
headingDeg: 30,
},
{
id: 'qa-camera-b',
name: 'QA Camera B',
lat: 30.2672,
lon: -97.7431,
headingDeg: 200,
},
].map((camera) => ({
...camera,
city: 'Austin',
cityId: 'austin',
provider: 'QA camera fixture',
feedType: 'snapshot',
feedConfigured: true,
pitchDeg: -18,
fovDeg: 60,
rangeM: 300,
mountHeightM: 8,
}));
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, ok, detail) => {
console.log(
`[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`,
);
if (!ok) failures++;
};
let delayCameraA = false;
const delayedFrames = new Set();
try {
await page.setViewport({ width: 1440, height: 900 });
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (url.origin !== new URL(origin).origin) return void request.continue();
const json = (body) =>
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
if (url.pathname === '/api/cctv/sources') return void json({ sources });
if (url.pathname === '/api/cctv/health')
return void json({
cameras: sources.map((camera) => ({
id: camera.id,
status: 'ok',
sourceKind: 'snapshot',
label: 'QA camera fixture',
})),
});
if (url.pathname.startsWith('/api/cctv/frame/')) {
const cameraA = url.pathname.endsWith('qa-camera-a');
const respond = () =>
request
.respond({
status: 200,
contentType: 'image/svg+xml',
headers: { 'Cache-Control': 'no-store' },
body: `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${cameraA ? '#16364d' : '#254b32'}"/><path d="M0 260L640 130M240 0L420 360" stroke="#97acb7" stroke-width="26"/><text x="30" y="55" fill="white" font-size="30">QA CAMERA ${cameraA ? 'A' : 'B'}</text></svg>`,
})
.catch(() => {});
if (cameraA && delayCameraA) delayedFrames.add(respond);
else void respond();
return;
}
if (url.pathname === '/api/terrain/heights') {
const points = (url.searchParams.get('points') || '')
.split(';')
.filter(Boolean);
return void json({
results: points.map((point) => {
const [lon, lat] = point.split(',').map(Number);
return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 };
}),
});
}
if (url.pathname === '/api/openai/hud-summary')
return void json({ summary: 'QA camera controls' });
void request.continue();
});
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._cctvControls &&
window.__godsEyeView?.dataManager?.layers?.has('cctv') &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.$eval('[data-collapse-target="cctv-panel"]', (button) =>
button.click(),
);
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.cameras.length === 2 &&
!document.getElementById('cctv-camera-select').disabled,
{ timeout: 30000 },
);
check(
'CCTV enable uses the supplied two-camera catalog',
await page.$eval(
'#cctv-camera-select',
(select) => select.options.length === 2 && !select.disabled,
),
);
await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-a' &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
const state = () =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(),
);
check(
'selected camera, decoded preview and source label agree',
await page.evaluate(() => {
const camera = window.__godsEyeView.dataManager.layers
.get('cctv')
.module.getUIState().activeCamera;
return (
camera.id === 'qa-camera-a' &&
document.getElementById('cctv-frame').src.includes('qa-camera-a') &&
document
.getElementById('cctv-meta')
.textContent.includes('QA camera fixture')
);
}),
);
const coverage = (await state()).coverageMode;
const modes = [];
for (let i = 0; i < 3; i++) {
await page.click('#cctv-coverage-btn');
modes.push((await state()).coverageMode);
}
check(
'coverage cycles through all three modes and returns to its starting state',
new Set(modes).size === 3 && modes.at(-1) === coverage,
modes,
);
const projection = (await state()).showProjection;
await page.click('#cctv-projection-btn');
check(
'projection control updates the layer state',
(await state()).showProjection === !projection,
);
await page.click('#cctv-projection-btn');
await page.click('#cctv-next-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.activeCameraId === 'qa-camera-b',
);
await page.click('#cctv-prev-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.activeCameraId === 'qa-camera-a',
);
check(
'native Next and Previous return to the original camera',
(await state()).activeCameraId === 'qa-camera-a',
);
for (const [selector, key] of [
['#cctv-auto-hop-btn', 'autoHop'],
['#cctv-adjust-btn', 'calibrationMode'],
]) {
const before = (await state())[key];
await page.click(selector);
const changed = (await state())[key] === !before;
await page.click(selector);
check(
`${key} control changes and restores the layer setting`,
changed && (await state())[key] === before,
);
}
// Auto Hop can legitimately advance immediately; restore the calibration target.
await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.activeCameraId === 'qa-camera-a',
);
const heading = (await state()).activeCamera.headingDeg;
const chip = '.cctv-cal-value[data-cal-field="heading"]';
await page.click(chip);
await page.focus(`${chip} input`);
await page.keyboard.down(selectAllModifier);
await page.keyboard.press('a');
await page.keyboard.up(selectAllModifier);
await page.keyboard.type(String(heading + 10));
await page.keyboard.press('Enter');
check(
'native calibration editing commits an offset for the selected camera',
Math.abs((await state()).activeCamera.headingDeg - (heading + 10)) < 0.01,
);
await page.click('#cctv-calib-save-btn');
check(
'Save persists the selected camera calibration',
await page.evaluate(() =>
Boolean(
JSON.parse(
localStorage.getItem('godsEyeView.cctv.calibration.v2') || '{}',
)['qa-camera-a'],
),
),
);
await page.click('#cctv-calib-reset-btn');
check(
'Reset restores the original pose',
Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01,
);
await page.click(chip);
await page.focus(`${chip} input`);
await page.keyboard.down(selectAllModifier);
await page.keyboard.press('a');
await page.keyboard.up(selectAllModifier);
await page.keyboard.type('110');
await page.keyboard.press('Escape');
check(
'Escape cancels editing without collapsing CCTV',
(await page.evaluate(
() =>
!document.querySelector('.cctv-cal-input') &&
!document.getElementById('cctv-panel').classList.contains('collapsed'),
)) && Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01,
);
await page.click(chip);
await page.evaluate(() => {
const input = document.querySelector('.cctv-cal-input');
input.value = '110';
window.__godsEyeView.dataManager.layers
.get('cctv')
.module.selectCamera('qa-camera-b');
input.dispatchEvent(new Event('blur'));
});
check(
'an external camera selection cancels the prior calibration editor',
(await state()).activeCamera.id === 'qa-camera-b' &&
Math.abs((await state()).activeCamera.headingDeg - 200) < 0.01 &&
(await page.$('.cctv-cal-input')) === null,
);
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-b' &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
await page.setCacheEnabled(false);
delayCameraA = true;
await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-a' &&
document.getElementById('cctv-frame').dataset.loading === 'true',
);
await page.evaluate(() => {
const image =
window.__godsEyeView.styleManager._cctvControls._cctvFramePreloader;
window.__qaLateCctvImage = image;
window.__qaLateCctvImageLoaded = false;
image.addEventListener(
'load',
() => {
window.__qaLateCctvImageLoaded = true;
},
{ once: true },
);
});
check(
'a newly selected camera clears old pixels while its frame loads',
await page.$eval(
'#cctv-frame-wrap',
(element) => !element.classList.contains('has-frame'),
),
);
await page.select('#cctv-camera-select', 'qa-camera-b');
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-b' &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
delayCameraA = false;
await Promise.all([...delayedFrames].map((respond) => respond()));
delayedFrames.clear();
await page.waitForFunction(() => window.__qaLateCctvImageLoaded);
check(
'late frames cannot overwrite the latest camera selection',
await page.$eval(
'#cctv-frame',
(image) =>
image.dataset.cameraId === 'qa-camera-b' &&
image.src.includes('qa-camera-b'),
),
);
delayCameraA = false;
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const disabledCleanly = await page.$eval(
'#cctv-frame',
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
);
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.isEnabled('cctv') &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
check(
'disable clears the preview and re-enable reacquires the camera frame',
disabledCleanly,
);
await page.$eval('#cctv-frame', (image) =>
image.scrollIntoView({ block: 'nearest' }),
);
fs.mkdirSync('qa-shots/camera-controls', { recursive: true });
await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' });
await page.evaluate(() => {
const camera = window.__godsEyeView.viewer.camera;
camera.setView({
orientation: {
heading: camera.heading + Math.PI / 3,
pitch: -0.6,
roll: 0,
},
});
});
await new Promise((resolve) => setTimeout(resolve, 700));
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
await page.setViewport({ width: 390, height: 844 });
await new Promise((resolve) => setTimeout(resolve, 700));
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
check(
'narrow camera panel remains within the viewport',
await page.$eval('#cctv-panel', (panel) => {
const rect = panel.getBoundingClientRect();
return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth + 1;
}),
);
check(
'rebuilding Radio preserves the active camera controller and subscription',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
const camera = ui._cctvControls;
const cameraId = camera.getState()?.activeCameraId;
ui._initRadioPanel();
ui._radioControls.connect();
return (
ui._cctvControls === camera &&
!camera.destroyed &&
Boolean(camera._cctvUnsubscribe) &&
camera.getState()?.activeCameraId === cameraId
);
}),
);
await page.evaluate(() => window.__godsEyeView.styleManager.dispose());
check(
'CCTV controller is destroyed with the real UI',
await page.evaluate(
() => window.__godsEyeView.styleManager._cctvControls.destroyed,
),
);
check(
'camera controls produce no uncaught browser errors',
errors.length === 0,
errors,
);
} finally {
await Promise.all([...delayedFrames].map((respond) => respond()));
await browser.close();
}
if (failures) process.exitCode = 1;
+818 -325
View File
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -46,7 +46,7 @@
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
*
* Defaults to the real GPU (ANGLE/Metal). `--swiftshader` selects software GL,
* Defaults to ANGLE/Metal on macOS and software GL elsewhere. `--swiftshader` selects software GL,
* which is deterministic but is NOT real-GPU evidence; the banner says which
* backend actually ran and the run records it alongside the results.
*/
@@ -64,7 +64,7 @@ const APP_ORIGIN = new URL(APP_URL).origin;
const TAG = getOpt('--tag', 'after');
const HEADFUL = argv.includes('--headful');
const TEETH = argv.includes('--teeth');
const SWIFTSHADER = argv.includes('--swiftshader');
const SWIFTSHADER = argv.includes('--swiftshader') || process.platform !== 'darwin';
const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
@@ -139,7 +139,7 @@ const CHROME_CANDIDATES = [
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome:
// its software-GL behavior shifts across majors and has produced false
// negatives in this repo's harnesses before.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
@@ -222,6 +222,8 @@ async function main() {
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
// Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall
// back to the software rasterizer or WebGL init fails outright.
...(SWIFTSHADER
? ['--use-gl=angle', '--use-angle=swiftshader']
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
@@ -292,7 +294,7 @@ async function main() {
console.log(` GL : ${renderer}\n`);
record(
SWIFTSHADER
? 'running on software GL as requested (structural checks, not GPU evidence)'
? 'running on software GL (structural checks, not GPU evidence)'
: 'running on the real GPU, so the screenshots are real-GPU evidence',
SWIFTSHADER ? softwareRenderer : !softwareRenderer,
renderer,
+363 -191
View File
@@ -13,14 +13,20 @@ fs.mkdirSync(shotsDir, { recursive: true });
const chromeCandidates = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate));
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
});
const page = await browser.newPage();
const failures = [];
@@ -54,8 +60,54 @@ const check = (name, passed, detail) => {
try {
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true);
page.on('request', (request) => {
const routeQaRequest = (request) => {
const url = new URL(request.url());
// These scenarios exercise Context lifecycle and keyboard ownership, not
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
// CelesTrak outages cannot invalidate an otherwise clean UI run.
if (url.origin === new URL(appUrl).origin
&& ['/api/celestrak/active', '/api/celestrak/starlink'].includes(url.pathname)) {
const dense = url.pathname.endsWith('/starlink');
request.respond({
status: 200,
contentType: 'text/plain',
body: (dense ? [
'STARLINK-1007',
'1 44713U 19074A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
'2 44713 53.0000 247.4627 0006703 130.5360 325.0288 15.06000000 12345',
] : [
'ISS (ZARYA)',
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49814310 12345',
]).join('\n') + '\n',
});
return;
}
// Space Missions is toggled by the Context cancellation checks below.
// Its catalog is unrelated to aircraft/Cockpit behavior; keep the network
// error gate meaningful without depending on Launch Library availability.
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/launches') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [] }),
});
return;
}
// This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
// unrelated upstream terrain outage out of the rendered interaction gate.
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
const points = (url.searchParams.get('points') || '').split(';').filter(Boolean);
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: points.map((point) => {
const [lon, lat] = point.split(',').map(Number);
return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 };
}) }),
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
request.respond({
status: 200,
@@ -111,7 +163,8 @@ try {
return;
}
request.continue();
});
};
page.on('request', routeQaRequest);
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
await page.waitForFunction(
@@ -142,21 +195,21 @@ try {
const toasts = [];
styleManager._showToast = (message) => { toasts.push(String(message)); };
try {
if (styleManager._contextMode) await styleManager._selectContextMode(null);
const contactsResult = await styleManager._selectContextMode('flights');
const missionResult = await styleManager._selectContextMode('space-missions');
if (styleManager._contextControls._contextMode) await styleManager._contextControls._selectContextMode(null);
const contactsResult = await styleManager._contextControls._selectContextMode('flights');
const missionResult = await styleManager._contextControls._selectContextMode('space-missions');
const switched = {
contactsResult,
missionResult,
mode: styleManager._contextMode,
mode: styleManager._contextControls._contextMode,
missionsEnabled: dataManager.isEnabled('rocket-launches'),
toasts: [...toasts],
};
const exitResult = await styleManager._selectContextMode(null);
const exitResult = await styleManager._contextControls._selectContextMode(null);
return {
...switched,
exitResult,
modeAfterExit: styleManager._contextMode,
modeAfterExit: styleManager._contextControls._contextMode,
missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'),
};
} finally {
@@ -239,16 +292,16 @@ try {
const deadline = performance.now() + 5_000;
while (performance.now() < deadline) {
if (
styleManager._contextModeEntering === null
&& styleManager._contextSessionSnapshot === null
styleManager._contextControls._contextModeEntering === null
&& styleManager._contextControls._contextSessionSnapshot === null
&& dataManager.isEnabled(siblingId)
&& !dataManager.isEffectivelyEnabled('rocket-launches')
) break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
timedOut = !(
styleManager._contextModeEntering === null
&& styleManager._contextSessionSnapshot === null
styleManager._contextControls._contextModeEntering === null
&& styleManager._contextControls._contextSessionSnapshot === null
&& dataManager.isEnabled(siblingId)
&& !dataManager.isEffectivelyEnabled('rocket-launches')
);
@@ -256,8 +309,8 @@ try {
exercised: true,
transitionResult,
timedOut,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEnabled(siblingId),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts,
@@ -340,7 +393,7 @@ try {
rocketEntry.module.disable = async () => true;
styleManager._showToast = (message) => { toasts.push(String(message)); };
try {
const entry = styleManager._selectContextMode('space-missions');
const entry = styleManager._contextControls._selectContextMode('space-missions');
await updateStarted;
const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
releaseUpdate();
@@ -348,15 +401,15 @@ try {
const committed = {
entryResult,
replacementResult,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
missionEnabled: dataManager.isEnabled('rocket-launches'),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts: [...toasts],
};
const exitResult = await styleManager._selectContextMode(null);
const exitResult = await styleManager._contextControls._selectContextMode(null);
return {
exercised: true,
...committed,
@@ -440,7 +493,7 @@ try {
styleManager._showToast = (message) => { toasts.push(String(message)); };
try {
const entry = styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._selectContextMode(
(notificationToken) => styleManager._contextControls._selectContextMode(
'space-missions',
{ notificationToken },
),
@@ -456,9 +509,9 @@ try {
exercised: true,
entryResult,
offResult,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEnabled(siblingId),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts,
@@ -525,35 +578,35 @@ try {
await new Promise((resolve) => setTimeout(resolve, 80));
const voiceState = {
voiceOn,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
};
const snapshotBeforeOff = [...(styleManager._contextSessionSnapshot?.enabledLayerIds || [])];
const snapshotBeforeOff = [...(styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds || [])];
const voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' });
const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size;
const reactionCountAfterOff = styleManager._contextControls._contextLayerReactionPromises.size;
await styleManager._waitForContextLayerSettlement();
const restoredSiblingEntry = dataManager.layers.get(siblingId);
const voiceExit = {
voiceOff,
snapshotBeforeOff,
reactionCountAfterOff,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEffectivelyEnabled(siblingId),
siblingEnabled: restoredSiblingEntry?.enabled,
siblingLifecycle: restoredSiblingEntry?.lifecycleState,
restoreActive: Boolean(styleManager._contextRestoreState),
restoreActive: Boolean(styleManager._contextControls._contextRestoreState),
};
await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
await new Promise((resolve) => setTimeout(resolve, 40));
const internalState = {
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'),
};
await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' });
@@ -665,7 +718,7 @@ try {
const blockerEntry = dataManager.layers.get(blockerId);
blockerEntry.initialized = true;
await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' });
const transition = styleManager._selectContextMode('flights');
const transition = styleManager._contextControls._selectContextMode('flights');
const disableStart = await settleWithin(disableStarted, gateTimeoutMs);
if (!disableStart.settled) {
releaseDisable();
@@ -683,7 +736,7 @@ try {
styleManager.cockpitView.syncEntry();
const entry = document.getElementById('cockpit-entry');
const pending = {
changing: styleManager._contextModeChanging,
changing: styleManager._contextControls._contextModeChanging,
entryHidden: Boolean(entry?.hidden),
enterResult: styleManager.cockpitView.enter(),
trackerPreserved: viewer.trackedEntity === trackedEntityBefore,
@@ -721,7 +774,7 @@ try {
// probe must not poison the checks that run after it.
releaseDisable();
if (!deferCleanup && dataManager.layers.has(blockerId)) {
if (styleManager._contextSessionSnapshot) await styleManager._selectContextMode(null);
if (styleManager._contextControls._contextSessionSnapshot) await styleManager._contextControls._selectContextMode(null);
await window.__gevQaUnregisterLayer(dataManager, blockerId);
styleManager.cockpitView.syncEntry();
}
@@ -768,146 +821,189 @@ try {
// was created to fix.
// Holding the network reproduces the field exactly and the panel tracks it,
// which is what gives these assertions teeth.
const deferredInstallationReadiness = await page.evaluate(async () => {
const { styleManager, dataManager } = window.__godsEyeView;
const entry = dataManager.layers.get('military-installations');
if (!entry?.module) return { exercised: false, reason: 'military-installations missing' };
const settleWithin = (promise, ms) => Promise.race([
promise.then(
(value) => ({ settled: true, value }),
(error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }),
),
new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }),
]);
const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')]
.find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations');
const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null;
/** The REASON text, which is what tracks availability: the cohort engine
* answers 'feed unavailable' / 'feed stale' when it refuses to count. */
const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null;
const realFetch = window.fetch;
let released = false;
let requestSeen = false;
let transition = null;
try {
await styleManager._selectContextMode(null);
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
// Hold the first Overpass request open, honouring the module's own
// AbortSignal so its camera-settle abort/refetch behaves as it does live.
window.fetch = (input, init) => {
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
let url = null;
try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
if (url.pathname !== '/api/military-installations') return realFetch(input, init);
requestSeen = true;
const signal = init?.signal;
return new Promise((resolve, reject) => {
let done = false;
const fail = () => {
if (done) return;
done = true;
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
};
if (signal) {
if (signal.aborted) return fail();
signal.addEventListener('abort', fail, { once: true });
}
const tick = () => {
if (done) return;
if (released) {
done = true;
resolve(new Response(
JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
));
return;
}
setTimeout(tick, 100);
};
tick();
// Earlier scenarios have already loaded installations. Use a fresh page so
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
// assertion when camera motion supersedes the manager's initial update.
const readinessPage = await browser.newPage();
let deferredInstallationReadiness;
try {
await readinessPage.setViewport({ width: 1440, height: 900 });
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
const now = Math.floor(Date.now() / 1000);
request.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify({ time: now, states: [
['aaa051', 'QA051', 'Synthetic', now, now, -97.7431, 30.2672,
9000, false, 230, 90, 0, null, 9000, null, false, 0],
] }),
});
};
transition = styleManager._selectContextMode('flights');
// The activation promise stays PENDING while the first fetch is out, and
// the Contacts panel comes up behind it — that is what a deferred
// dependency is for. Verified live on :4272: the panel renders and reads
// a non-numeric count for the whole of a 17 s first fetch.
const started = await settleWithin((async () => {
for (let i = 0; i < 100 && !(requestSeen && row()); i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return requestSeen && Boolean(row());
})(), 12_000);
styleManager.cockpitView.syncEntry();
const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations');
const pendingCount = installationCount();
const pendingReason = installationReason();
// THE WINDOW: first request outstanding, panel live. Sampled more than
// once — the field report was a panel sitting on a fabricated 0 for ~13 s,
// so a single lucky read is not evidence.
const fetchingCounts = [];
const fetchingReasons = [];
for (let i = 0; i < 6; i++) {
fetchingCounts.push(installationCount());
fetchingReasons.push(installationReason());
await new Promise((resolve) => setTimeout(resolve, 250));
return;
}
released = true;
const contacts = await settleWithin(transition, 20_000);
styleManager.cockpitView.syncEntry();
// Measured AFTER activation settles, not during it. The sibling pin above
// ("Contacts blocks early Cockpit entry, then adopts the already tracked
// flight after settlement") establishes that Contacts HIDES the entry
// while the transition is changing — that is the designed contract, and
// asserting the opposite (as this scenario used to) could never pass.
// What matters is that a slow dependency does not lock it away for good.
const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden;
const installed = await settleWithin((async () => {
while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 200));
return true;
})(), 20_000);
const answered = await settleWithin((async () => {
for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
return true;
})(), 12_000);
return {
exercised: true,
started,
contacts,
pendingLifecycle,
pendingCount,
pendingReason,
cockpitAvailableAfterActivation,
installed,
settledLifecycle: dataManager.getLayerLifecycleState('military-installations'),
fetchingCounts,
fetchingReasons,
answered,
settledCount: installationCount(),
settledReason: installationReason(),
};
} finally {
released = true;
window.fetch = realFetch;
if (transition) await settleWithin(transition, 20_000);
if (styleManager._contextMode !== 'flights') {
await settleWithin(styleManager._selectContextMode('flights'), 15_000);
routeQaRequest(request);
});
const readinessUrl = new URL(appUrl);
readinessUrl.searchParams.set('welcome', '0');
await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 });
await readinessPage.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
await manager.setEnabled('flights', true, { origin: 'user' });
if (!manager.layers.get('flights').module.trackById('aaa051')) {
throw new Error('First-fetch fixture aircraft was not tracked');
}
}
});
if (manager.layers.get('military-installations').module.getStats().lastUpdate != null) {
throw new Error('First-fetch fixture already has an installation snapshot');
}
});
deferredInstallationReadiness = await readinessPage.evaluate(async () => {
const { styleManager, dataManager } = window.__godsEyeView;
const entry = dataManager.layers.get('military-installations');
if (!entry?.module) return { exercised: false, reason: 'military-installations missing' };
const settleWithin = (promise, ms) => Promise.race([
promise.then(
(value) => ({ settled: true, value }),
(error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }),
),
new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }),
]);
const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')]
.find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations');
const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null;
/** The REASON text, which is what tracks availability: the cohort engine
* answers 'feed unavailable' / 'feed stale' when it refuses to count. */
const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null;
const realFetch = window.fetch;
let released = false;
let requestSeen = false;
let transition = null;
try {
await styleManager._contextControls._selectContextMode(null);
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
// Hold the first Overpass request open, honouring the module's own
// AbortSignal so its camera-settle abort/refetch behaves as it does live.
window.fetch = (input, init) => {
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
let url = null;
try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
if (url.pathname !== '/api/military-installations') return realFetch(input, init);
requestSeen = true;
const signal = init?.signal;
return new Promise((resolve, reject) => {
let done = false;
const fail = () => {
if (done) return;
done = true;
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
};
if (signal) {
if (signal.aborted) return fail();
signal.addEventListener('abort', fail, { once: true });
}
const tick = () => {
if (done) return;
if (released) {
done = true;
resolve(new Response(
JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
));
return;
}
setTimeout(tick, 100);
};
tick();
});
};
transition = styleManager._contextControls._selectContextMode('flights');
// The activation promise stays PENDING while the first fetch is out, and
// the Contacts panel comes up behind it — that is what a deferred
// dependency is for. Verified live on :4272: the panel renders and reads
// a non-numeric count for the whole of a 17 s first fetch.
const started = await settleWithin((async () => {
for (let i = 0; i < 100 && !(requestSeen && row()); i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return requestSeen && Boolean(row());
})(), 12_000);
styleManager.cockpitView.syncEntry();
const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations');
const pendingCount = installationCount();
const pendingReason = installationReason();
// THE WINDOW: first request outstanding, panel live. Sampled more than
// once — the field report was a panel sitting on a fabricated 0 for ~13 s,
// so a single lucky read is not evidence.
const fetchingCounts = [];
const fetchingReasons = [];
for (let i = 0; i < 6; i++) {
fetchingCounts.push(installationCount());
fetchingReasons.push(installationReason());
await new Promise((resolve) => setTimeout(resolve, 250));
}
released = true;
const contacts = await settleWithin(transition, 20_000);
styleManager.cockpitView.syncEntry();
// Measured AFTER activation settles, not during it. The sibling pin above
// ("Contacts blocks early Cockpit entry, then adopts the already tracked
// flight after settlement") establishes that Contacts HIDES the entry
// while the transition is changing — that is the designed contract, and
// asserting the opposite (as this scenario used to) could never pass.
// What matters is that a slow dependency does not lock it away for good.
const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden;
const installed = await settleWithin((async () => {
while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 200));
return true;
})(), 20_000);
const answered = await settleWithin((async () => {
for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) {
await new Promise((resolve) => setTimeout(resolve, 150));
}
return true;
})(), 12_000);
return {
exercised: true,
started,
contacts,
pendingLifecycle,
pendingCount,
pendingReason,
cockpitAvailableAfterActivation,
installed,
settledLifecycle: dataManager.getLayerLifecycleState('military-installations'),
fetchingCounts,
fetchingReasons,
answered,
settledCount: installationCount(),
settledReason: installationReason(),
};
} finally {
released = true;
window.fetch = realFetch;
if (transition) await settleWithin(transition, 20_000);
if (styleManager._contextControls._contextMode !== 'flights') {
await settleWithin(styleManager._contextControls._selectContextMode('flights'), 15_000);
}
}
});
} finally {
await readinessPage.close();
await page.bringToFront();
}
// The panel has two truthful ways to say "nothing has answered yet" — the
// cohort count renders `?`, and an UNKNOWN relationship renders as that word.
// Which one is on screen depends on which panel surface is mounted, and the
@@ -916,6 +1012,11 @@ try {
// assert numeric-vs-not, which is exactly the field report.
const readsUnknown = (value) => typeof value === 'string' && value.length > 0 && !/^\d/.test(value);
const readsNumber = (value) => typeof value === 'string' && /^\d+$/.test(value);
// Loading has explicit copy since the installations reliability update.
// Keep the numeric/count gates below: wording alone never proves readiness.
const readsPendingReason = (value) => (
/unavailable|stale|Mapped sites not loaded|Fetching mapped sites…/i.test(String(value))
);
check(
'mapped installations load behind Contacts without a false all-clear or a locked Cockpit',
deferredInstallationReadiness.exercised
@@ -924,7 +1025,7 @@ try {
&& deferredInstallationReadiness.contacts.value === true
&& deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling'
&& readsUnknown(deferredInstallationReadiness.pendingCount)
&& /unavailable|stale/i.test(String(deferredInstallationReadiness.pendingReason))
&& readsPendingReason(deferredInstallationReadiness.pendingReason)
&& deferredInstallationReadiness.cockpitAvailableAfterActivation
&& deferredInstallationReadiness.installed?.settled,
JSON.stringify(deferredInstallationReadiness),
@@ -937,10 +1038,11 @@ try {
&& deferredInstallationReadiness.fetchingCounts.length > 0
&& deferredInstallationReadiness.fetchingCounts.every(readsUnknown)
&& Array.isArray(deferredInstallationReadiness.fetchingReasons)
&& deferredInstallationReadiness.fetchingReasons.every((r) => /unavailable|stale/i.test(String(r))),
&& deferredInstallationReadiness.fetchingReasons.every(readsPendingReason),
JSON.stringify({
settledLifecycle: deferredInstallationReadiness.settledLifecycle,
fetchingCounts: deferredInstallationReadiness.fetchingCounts,
fetchingReasons: deferredInstallationReadiness.fetchingReasons,
}),
);
check(
@@ -948,7 +1050,7 @@ try {
deferredInstallationReadiness.exercised
&& deferredInstallationReadiness.answered?.settled
&& readsNumber(deferredInstallationReadiness.settledCount)
&& !/unavailable|stale/i.test(String(deferredInstallationReadiness.settledReason)),
&& !readsPendingReason(deferredInstallationReadiness.settledReason),
JSON.stringify({
answered: deferredInstallationReadiness.answered,
settledCount: deferredInstallationReadiness.settledCount,
@@ -969,7 +1071,7 @@ try {
return {
before: before ? { layerId: before.layerId, id: before.id } : null,
after: after ? { layerId: after.layerId, id: after.id } : null,
mode: styleManager._contextMode,
mode: styleManager._contextControls._contextMode,
released,
refocused,
trackedId: trackedInfo?.icao24 || trackedInfo?.id || null,
@@ -1000,7 +1102,12 @@ try {
);
const zoomedRange = cameraRange();
const refocused = awareness?.focusCurrent?.() === true;
await new Promise((resolve) => setTimeout(resolve, 180));
// The follow frame commits in Cesium preUpdate. A fixed 180ms delay can
// sample before that frame on a busy software renderer.
const frameDeadline = performance.now() + 5000;
while (cameraRange() >= 50_000 && performance.now() < frameDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const focusedRange = cameraRange();
const after = awareness?.getContextSnapshot?.()?.subject || null;
return {
@@ -1110,9 +1217,9 @@ try {
// Start from outside both, so activation is a real edge.
cockpit.exit({ restoreTracking: true });
await settle();
await styleManager._selectContextMode(null);
await styleManager._contextControls._selectContextMode(null);
await settle(400);
const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null;
const cleanupSnapshotCleared = styleManager._contextControls._contextSessionSnapshot === null;
const cleanupBlockerId = '__qa_slow_contacts_sibling__';
const cleanupUnregistered = await window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager,
@@ -1128,7 +1235,7 @@ try {
const overriddenBeforeContacts = styleManager._detectionUserOverridden;
// 1. Activating Contacts forces the tactical preset on.
const contactsOn = await styleManager._selectContextMode('flights');
const contactsOn = await styleManager._contextControls._selectContextMode('flights');
await settle(400);
const afterContactsOn = mode();
const afterContactsOnDensity = density();
@@ -1158,7 +1265,7 @@ try {
await settle();
// 4. Deactivating Contacts restores the pre-Contacts state.
await styleManager._selectContextMode(null);
await styleManager._contextControls._selectContextMode(null);
await settle(400);
const afterContactsOff = mode();
const afterContactsOffDensity = density();
@@ -1172,7 +1279,7 @@ try {
await settle();
// Leave the session exactly as the following checks expect it.
const restoredContacts = await styleManager._selectContextMode('flights');
const restoredContacts = await styleManager._contextControls._selectContextMode('flights');
await settle(400);
const restoredCockpit = await enterCockpit();
styleManager._detectionUserOverridden = originalOverridden;
@@ -1252,8 +1359,8 @@ try {
step,
cockpitActive: cockpit.active,
bodyCockpit: document.body.classList.contains('cockpit-mode'),
contextMode: styleManager._contextMode,
contextChanging: styleManager._contextModeChanging,
contextMode: styleManager._contextControls._contextMode,
contextChanging: styleManager._contextControls._contextModeChanging,
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
tracked: tracker?.gevTrackedId || null,
density: styleManager.getDetectionState().densityPct,
@@ -1339,12 +1446,25 @@ try {
const { styleManager, dataManager, viewer } = window.__godsEyeView;
const awareness = dataManager.layers.get('military-awareness')?.module;
const entity = viewer.trackedEntity || styleManager.cockpitView.trackedEntity;
const nextFrame = () => new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('Cockpit camera ownership check did not render a frame'));
}, 5000);
const remove = viewer.scene.postRender.addEventListener(() => {
remove();
clearTimeout(timer);
resolve();
});
});
// Let revoked listeners retire before comparing ownership across exit.
await nextFrame();
const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners;
const exited = styleManager.cockpitView.exit() === true;
await new Promise((resolve) => setTimeout(resolve, 80));
await nextFrame();
const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners;
const refocused = awareness?.focusCurrent?.() === true;
await new Promise((resolve) => setTimeout(resolve, 80));
await nextFrame();
return {
exited,
refocused,
@@ -1907,6 +2027,58 @@ try {
&& contextTransition.label === 'Collapse Contact panel',
JSON.stringify(contextTransition),
);
// Use the real connected DOM to catch focus loss caused by moving live rows.
const signalFocus = await page.evaluate(() => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
const originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try {
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus();
const acquired = document.activeElement === button;
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
cockpit.renderCockpitSignals();
const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
cockpit.signalItems = [item('focus-b')];
cockpit.renderCockpitSignals();
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation;
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally {
cockpit.signalItems = originalItems;
cockpit.renderCockpitSignals();
cockpit.setSignalCollapsed(wasCollapsed);
}
});
check('Cockpit live reorder retains the same connected focus owner',
signalFocus.acquired && signalFocus.retained
&& JSON.stringify(signalFocus.order) === JSON.stringify(['focus-c', 'focus-b', 'focus-a']),
JSON.stringify(signalFocus));
check('a departed Cockpit contact continues at the footer without later reclaiming focus',
signalFocus.continued && signalFocus.notReclaimed, JSON.stringify(signalFocus));
// Real keyboard events also verify the nested lightbox does not exit Cockpit.
await page.focus('#cesium-credits .cesium-credit-expand-link');
await page.keyboard.press('Enter');
check('keyboard attribution opening focuses Close inside Cockpit', await page.evaluate(() => (
document.activeElement?.classList.contains('cesium-credit-lightbox-close')
&& document.querySelector('.cesium-credit-expand-link').getAttribute('aria-expanded') === 'true'
)));
await page.screenshot({ path: path.join(shotsDir, 'keyboard-attribution.png') });
await page.keyboard.press('Escape');
check('attribution Escape restores focus and keeps Cockpit active', await page.evaluate(() => (
document.activeElement?.classList.contains('cesium-credit-expand-link')
&& document.activeElement.getAttribute('aria-expanded') === 'false'
&& window.__godsEyeView.styleManager.cockpitView.active
)));
await page.screenshot({ path: path.join(shotsDir, 'restored-desktop.png') });
check(
'restored screenshot remains in a real Cockpit session',
+1 -1
View File
@@ -93,7 +93,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+1 -1
View File
@@ -70,7 +70,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+70 -30
View File
@@ -24,6 +24,8 @@
* Visual proof saved to qa-shots/ (gitignored).
*
* Run: node scripts/qa-firms.mjs --url http://localhost:4420
* Add --fixtures for deterministic source/rendering proof without a key;
* that mode does not establish live-source acceptance.
* Exits non-zero on any FAIL. Does not commit anything.
*/
@@ -43,6 +45,7 @@ const getOpt = (name, dflt) => {
};
const APP_URL = getOpt('--url', 'http://localhost:4420');
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -52,7 +55,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
@@ -157,7 +160,8 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(1500);
await sleep(5000);
await page.keyboard.press('Escape');
return page.evaluate(async (tS) => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('local-firms', true);
@@ -208,8 +212,20 @@ function stalePayload() {
};
}
/** Deterministic high-density source records for refactoring without a server key. */
function freshFixturePayload() {
const template = stalePayload();
const fires = Array.from({ length: 1600 }, (_, i) => ({
...template.fires[i % template.fires.length],
lat: 30.1 + (i % 40) * 0.008,
lon: -97.9 + Math.floor(i / 40) * 0.008,
frp: 5 + (i % 137),
}));
return { ...template, fetchedAt: Date.now(), stale: false, count: fires.length, fires };
}
async function main() {
console.log('\nLive NASA FIRMS Proof (qa-firms)');
console.log(FIXTURES ? '\nFIRMS fixture proof (no live-source acceptance)' : '\nLive NASA FIRMS Proof (qa-firms)');
console.log(` App URL : ${APP_URL}\n`);
try {
@@ -221,7 +237,7 @@ async function main() {
}
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
if (!FIXTURES && !status?.hasKey) {
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
process.exit(2);
}
@@ -243,14 +259,40 @@ async function main() {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.setRequestInterception(true);
let interceptMode = FIXTURES ? 'fixture' : 'live';
const stableStalePayload = stalePayload();
page.on('request', (req) => {
const url = req.url();
if (interceptMode === 'live') { req.continue(); return; }
if (url.includes('/api/firms/status')) {
req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify(interceptMode === 'keyless'
? { hasKey: false }
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
});
return;
}
if (url.includes('/api/firms')) {
if (interceptMode === 'keyless') {
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
} else {
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(interceptMode === 'fixture' ? freshFixturePayload() : stableStalePayload) });
}
return;
}
try { req.continue(); } catch { /* already handled */ }
});
// ── (i) LIVE feed ────────────────────────────────────────────────────────
console.log('(i) LIVE — loading the fires layer through the cached proxy...');
console.log(FIXTURES ? '(i) FIXTURE — loading synthetic fires through the source adapter...' : '(i) LIVE — loading the fires layer through the cached proxy...');
const live = await bootAndEnable(page);
{
const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE');
record('LIVE: >1000 detections, no error, LIVE label', ok,
record(`${FIXTURES ? 'FIXTURE' : 'LIVE'}: >1000 detections, no error, LIVE label`, ok,
`count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`);
record('LIVE: /api/firms/status has key + transaction telemetry',
if (!FIXTURES) record('LIVE: /api/firms/status has key + quota statistics',
status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)),
`transactions=${JSON.stringify(status.transactions)} count=${status.count}`);
if (!ok) exitCode = 1;
@@ -299,29 +341,7 @@ async function main() {
// ── (iii) KEYLESS (intercepted) ──────────────────────────────────────────
console.log('\n(iii) KEYLESS — intercepted 503 no_key...');
await page.setRequestInterception(true);
let interceptMode = 'keyless';
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/firms/status')) {
req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify(interceptMode === 'keyless'
? { hasKey: false }
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
});
return;
}
if (url.includes('/api/firms')) {
if (interceptMode === 'keyless') {
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
} else {
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) });
}
return;
}
try { req.continue(); } catch { /* already handled */ }
});
interceptMode = 'keyless';
const keyless = await bootAndEnable(page, { timeoutS: 20 });
{
@@ -459,6 +479,26 @@ async function main() {
+ `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} `
+ `selectedActions=${proof.selectedActionCount}`);
if (!actionOk) exitCode = 1;
const refresh = await page.evaluate(async () => {
const layer = window.__godsEyeView.dataManager.layers.get('local-firms').module;
const before = layer.getSelectedInfo();
let selections = 0;
const count = () => { selections += 1; };
window.addEventListener('gev:entity-selected', count);
try {
await layer.update();
return { before, after: layer.getSelectedInfo(), selections,
contextId: window.__gevContextStore.selectedEntityId };
} finally {
window.removeEventListener('gev:entity-selected', count);
}
});
const refreshOk = Boolean(refresh.before?.id)
&& refresh.after?.id === refresh.before.id
&& refresh.contextId === refresh.before.id && refresh.selections === 0;
record('REFRESH: selected detection survives without another selection event', refreshOk,
JSON.stringify(refresh));
if (!refreshOk) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') });
}
}
+23 -21
View File
@@ -13,8 +13,8 @@
*
* Every touched file is restored on exit, including on failure.
*
* NOTE: two mutations edit vite.config.js, and a running dev server watches that
* file and restarts on every write. Writes are therefore content-guarded below
* NOTE: two mutations edit the server voice tools/instructions, and a running dev server watches those
* files and restarts on every write. Writes are therefore content-guarded below
* so the file is touched exactly twice per mutation instead of on every
* iteration — enough that a dev server survives, but expect it to restart. If
* you are mid-QA on a live server, run this before or after, not during.
@@ -25,14 +25,16 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TESTS = 'src/firstRunExperience.test.mjs';
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'index.html'),
css: path.join(ROOT, 'style.css'),
vite: path.join(ROOT, 'vite.config.js'),
main: path.join(ROOT, 'src', 'main.js'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
ui: path.join(ROOT, 'src', 'ui.js'),
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
};
@@ -117,8 +119,8 @@ const MUTATIONS = [
{
defect: 'the key handler trusts the CLASS, so an invisible card eats ESC',
file: 'module',
from: 'if (closing || !isTopmost()) return;',
to: 'if (closing) return;',
from: 'isActive: () => !closing && isTopmost(),',
to: 'isActive: () => !closing,',
},
{
defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC',
@@ -164,8 +166,8 @@ const MUTATIONS = [
},
{
defect: 'the launcher stops honouring a key another surface already claimed',
file: 'module',
from: ' if (event.defaultPrevented) return;',
file: 'keyboard',
from: ' || event.defaultPrevented',
to: ' /* belt removed */',
},
{
@@ -173,8 +175,8 @@ const MUTATIONS = [
// stopImmediatePropagation() a few lines below and prove nothing about this.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
file: 'ui',
from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });',
to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
@@ -343,25 +345,25 @@ const MUTATIONS = [
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
file: 'module',
file: 'keyboard',
from: "if (event.key !== 'Tab') return;",
to: 'return;',
},
{
defect: 'ESC no longer dismisses the launcher',
file: 'module',
file: 'keyboard',
from: "if (event.key === 'Escape') {",
to: 'if (false) {',
},
{
defect: 'focus is never returned to where the visitor left it',
file: 'module',
from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {",
file: 'keyboard',
from: "if (typeof target?.focus === 'function' && target.isConnected) {",
to: 'if (false) {',
},
{
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
file: 'module',
file: 'keyboard',
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
to: "documentRef.addEventListener('keydown', onKeyDown);",
},
@@ -452,15 +454,15 @@ const MUTATIONS = [
// ── Voice: schema must not drift ──────────────────────────────────────────
{
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
file: 'vite',
file: 'voiceTools',
from: " 'earthquakes',\n 'satellites',",
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
file: 'vite',
from: " 'NAMED VIEWS are shorthand",
to: " // 'NAMED VIEWS are shorthand",
file: 'voiceInstructions',
from: " 'NAMED VIEWS are shorthand",
to: " // 'NAMED VIEWS are shorthand",
},
];
@@ -491,7 +493,7 @@ for (const { defect, file, from, to } of MUTATIONS) {
let red = false;
let by = '';
try {
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
} catch (error) {
red = true;
const failed = String(error.stdout || '').split('\n')
+13 -7
View File
@@ -38,7 +38,7 @@ const SHOT_DIR = path.join(ROOT, 'qa-shots', 'firstrun');
const CHROME_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
@@ -332,14 +332,17 @@ async function runArbitrationSection(page, { shots, consoleErrors }) {
`session=${escUnderLightbox.session}`,
);
record(
'the lightbox keeps its OWN ESC semantics (Cesium binds none, so it stays)',
lightboxAfterEsc.shown,
'ESC closes attribution without dismissing the launcher underneath',
!lightboxAfterEsc.shown,
`overlay shown=${lightboxAfterEsc.shown}`,
);
// The guard disarms the launcher; it must never break it. Close the overlay
// the way a visitor does and the key comes straight back.
await page.evaluate(() => document.querySelector('.cesium-credit-lightbox-close')?.click());
// The attribution keyboard handler closes the overlay and restores focus.
// A second Escape belongs to the now-uncovered launcher.
const attributionFocusRestored = await page.evaluate(() => (
document.activeElement === document.querySelector('#cesium-credits .cesium-credit-expand-link')
));
record('closing attribution restores its disclosure focus', attributionFocusRestored);
await sleep(300);
const uncovered = await launcherState();
record('closing the lightbox hands the card back the key',
@@ -491,7 +494,10 @@ async function main() {
...(executablePath ? { executablePath } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox',
'--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist',
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--window-size=1440,900',
],
+23 -8
View File
@@ -15,7 +15,7 @@
* The contact must stay ON the mesh through phase B. Without the hold it falls
* to the geoid, which at an inland field is ~150 m of burial.
*
* node scripts/qa-floor-hold.mjs # real GPU (ANGLE/Metal)
* node scripts/qa-floor-hold.mjs # Metal on macOS, SwiftShader elsewhere
* node scripts/qa-floor-hold.mjs --headful # watch it
* QA_BASE_URL=http://localhost:4257 node scripts/qa-floor-hold.mjs
*
@@ -25,10 +25,11 @@
* under Metal — a backend comparison at one point, not to be confused with the
* same-backend run-to-run spread (122.1 m vs 20.6 m on a cell centre) that
* retired the hold chain's unvalidated tier. The
* default is the real GPU. Under `--angle=swiftshader` the rendered-mesh oracle
* is reported as unavailable rather than trusted, and the run falls back to the
* bare-earth DEM (fetched server-side, so the simulated outage cannot reach
* it) — a weaker but still decisive check, since the geoid sits ~150 m below it.
* default is Metal on macOS and SwiftShader elsewhere; `--angle` overrides it.
* Mesh samples describe the selected backend, not another GPU's rendered LOD.
* An unavailable rendered-mesh oracle remains a failed check, even if the
* separate bare-earth DEM check passes. Software evidence does not establish
* real-GPU visual correctness.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
@@ -37,7 +38,8 @@ import path from 'node:path';
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const argv = Object.fromEntries(process.argv.slice(2)
.map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true]));
const ANGLE = String(argv.angle || 'metal');
// Metal is a macOS-only ANGLE backend; defaulting to it off-Mac fails WebGL init.
const ANGLE = String(argv.angle || (process.platform === 'darwin' ? 'metal' : 'swiftshader'));
const HEADFUL = !!argv.headful;
// Austin airport apron by default — the site qa-floor-verify pins, where the
// mesh sits ~150 m above the geoid and burial is unmistakable.
@@ -56,7 +58,7 @@ function record(name, ok, detail) {
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const chrome = (() => { try { return puppeteer.executablePath(); } catch { return undefined; } })();
const chrome = await puppeteer.executablePath().catch(() => undefined);
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
executablePath: chrome,
@@ -108,6 +110,10 @@ await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
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(() => {
document.querySelector('#first-run-launcher:not([hidden]) [data-first-run-choice="explore"]')?.click();
});
await sleep(600); // let the launcher dismissal finish before taking evidence
await page.evaluate((site) => { window.__SITE = site; }, SITE);
const pin = () => page.evaluate(() => {
@@ -123,7 +129,16 @@ const pin = () => page.evaluate(() => {
v.camera.moveEnd.raiseEvent();
});
await pin();
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
const billboardMode = await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
const flights = manager.layers.get('flights').module;
// This harness measures billboard positions. A ready 3D model deliberately
// hides its billboard; that handoff is covered by track-regression instead.
flights.setParams({ models3d: false });
await manager.toggle('flights');
return flights.getParams().models3d === false;
});
record('the billboard floor test is explicitly in 2D aircraft mode', billboardMode);
/** Reads the contact's rendered height and the rendered mesh beneath it. */
const measure = () => page.evaluate(async (icao) => {
+1 -1
View File
@@ -26,7 +26,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// across majors.
const chrome = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean).find((p) => fs.existsSync(p));
+16 -15
View File
@@ -25,7 +25,7 @@ const TESTS = [
'src/data/renderAltitude.test.mjs',
];
const FLIGHTS = 'src/data/flights.js';
const FLIGHTS = 'src/layers/flights/motion.js';
const FLOOR = 'src/data/groundFloor.js';
const ALT = 'src/data/renderAltitude.js';
@@ -94,7 +94,7 @@ const MUTATIONS = [
{
defect: 'the held floor is stretched without bound (no drift limit)',
edits: [
{ file: FLIGHTS, from: ' <= HELD_FLOOR_MAX_DRIFT_KM)) {', to: ' <= Infinity)) {' },
{ file: FLIGHTS, from: '<= HELD_FLOOR_MAX_DRIFT_KM', to: '<= Infinity' },
],
},
{
@@ -102,8 +102,8 @@ const MUTATIONS = [
edits: [
{
file: FLIGHTS,
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
to: ' if (false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
to: 'false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
},
],
},
@@ -122,8 +122,8 @@ const MUTATIONS = [
edits: [
{
file: FLIGHTS,
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
to: ' if (next.easedM == null && Number.isFinite(stoodOnM)',
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
to: 'next.easedM == null && Number.isFinite(stoodOnM)',
},
],
},
@@ -159,8 +159,8 @@ const MUTATIONS = [
edits: [
{
file: FLIGHTS,
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
to: ' if (next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
to: 'next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)',
},
],
},
@@ -191,8 +191,8 @@ const MUTATIONS = [
edits: [
{
file: FLIGHTS,
from: ' const closed = Math.min(FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS));',
to: ' const closed = 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS);',
from: 'FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),',
to: 'Infinity, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),',
},
],
},
@@ -204,7 +204,7 @@ const MUTATIONS = [
{
file: FLIGHTS,
from: ' if (state.retiredMs == null) {',
to: ' _displayFloorState.delete(icao24);\n if (false) {',
to: ' flightState._displayFloorState.delete(icao24);\n if (false) {',
},
],
},
@@ -213,8 +213,8 @@ const MUTATIONS = [
edits: [
{
file: FLIGHTS,
from: ' return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS;',
to: ' return false;',
from: 'state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS',
to: 'false',
},
],
},
@@ -291,12 +291,13 @@ for (const mut of MUTATIONS) {
for (const e of mut.edits) {
if (!backups.has(e.file)) backups.set(e.file, read(e.file));
const cur = read(e.file);
if (!cur.includes(e.from)) {
const anchor = new RegExp(e.from.trim().split(/\s+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s*'));
if (!anchor.test(cur)) {
console.log(` [STALE] ${mut.defect} — anchor not found in ${e.file}`);
applied = false;
break;
}
write(e.file, cur.replace(e.from, e.to));
write(e.file, cur.replace(anchor, () => e.to.trim()));
}
if (applied) {
const r = runTests();
+1 -1
View File
@@ -27,7 +27,7 @@ const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000'));
const CHROME_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
].filter(Boolean);
const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } });
+1 -1
View File
@@ -123,7 +123,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+1 -1
View File
@@ -115,7 +115,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env node
/**
* qa-infra-lod — browser checks for the local-infrastructure LOD declutter.
*
* Datacenters and dams use a bounded active-stem set. Cables are loaded for
* a representative combined view but use their own unchanged renderer.
*
* Two jobs:
* 1. GATE — with datacenters + dams enabled:
* a. at a full-earth camera, each layer's getLodDiagnostics() reports
* computed===true, active<=budgetLimit, budgetLimit===INFRA_LOD_ACTIVE_MIN,
* and active<total (the declutter is actually engaged);
* b. flying to a city lifts budgetLimit to INFRA_LOD_ACTIVE_MAX and the
* active count grows (or pins to the in-view count);
* c. the selection does not churn between camera moves.
* 2. MEASURE — actual postRender frame intervals over a ~10 s driven orbit at
* global zoom; --control disables infra. This is not a before/after test.
* Report the renderer; software-GL timings are relative-only evidence.
*
* Visual proof saved to qa-shots/ (gitignored).
*
* Usage: node scripts/qa-infra-lod.mjs [--url http://localhost:5180] [--control] [--headful]
* Requires a running dev server with the bundled local_data present. Headless;
* rAF throttling disabled so the frame clock is honest. Exits non-zero on any
* FAIL. Commits nothing.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
INFRA_LOD_ACTIVE_MIN,
INFRA_LOD_ACTIVE_MAX,
} from '../src/data/localGeojsonLod.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const SHOTS_DIR = process.env.QA_SHOTS_DIR || path.join(REPO_ROOT, 'qa-shots');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:5180');
const CONTROL = argv.includes('--control');
const HEADFUL = argv.includes('--headful');
const INFRA_LAYER_IDS = ['local-datacenters', 'local-dams'];
const CABLE_LAYER_ID = 'telegeography-submarine-cables';
const results = [];
function check(name, pass, detail) {
results.push({ name, pass });
const tag = pass ? 'PASS' : 'FAIL';
console.log(` [${tag}] ${name}${detail !== undefined ? ` — ${JSON.stringify(detail)}` : ''}`);
}
function report(name, detail) {
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
}
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
protocolTimeout: 300_000,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--window-size=1440,900',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
});
try {
const page = await browser.newPage();
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(String(error.message).replace(/https?:\/\/\S+/g, '[URL]')));
await page.setViewport({ width: 1440, height: 860 });
const testUrl = new URL(APP_URL);
testUrl.searchParams.set('welcome', '0');
await page.goto(testUrl.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.styleManager
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90_000 });
check('first-run chooser does not cover the test surface', await page.evaluate(() => (
!document.querySelector('#first-run-launcher:not([hidden])')
)));
report('renderer', await page.evaluate(() => {
const gl = window.__godsEyeView.viewer.scene.context._gl;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
}));
// Park at a full-earth view and disable every layer so infra is measured
// in isolation.
await page.evaluate(async () => {
const gev = window.__godsEyeView;
const v = gev.viewer;
v.camera.cancelFlight();
const ell = v.scene.globe.ellipsoid;
v.camera.setView({
destination: ell.cartographicToCartesian({
longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
for (const [id, entry] of gev.dataManager.layers) {
if (entry.enabled) {
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ }
}
}
});
await new Promise((r) => setTimeout(r, 4_000));
// Enable the infrastructure layers (skipped in --control so the orbit cost
// measures the empty scene for attribution).
if (!CONTROL) {
const loaded = await page.evaluate(async (infraIds, cableId) => {
const gev = window.__godsEyeView;
const ids = [...infraIds, cableId];
for (const id of ids) {
try { await gev.dataManager.setEnabled(id, true, { origin: 'user' }); } catch { /* reported below */ }
}
const deadline = performance.now() + 60_000;
const stat = (id) => gev.dataManager.layers.get(id)?.module?.getStats?.() || {};
while (performance.now() < deadline) {
// Every id, cables included. The cable layer's enable() only starts
// `void load()`, and the manager's immediate update() returns early
// because `_loading` is already true — so waiting on datacenters and
// dams alone releases the measurement before ~2,600 cable references
// exist, and the advertised three-layer frame cost underreports.
const done = ids.every((id) => {
const s = stat(id);
return (s.count || 0) > 0 || s.error;
});
if (done) break;
gev.viewer.scene.requestRender?.();
await new Promise((r) => setTimeout(r, 200));
}
const out = {};
for (const id of ids) out[id] = stat(id);
return out;
}, INFRA_LAYER_IDS, CABLE_LAYER_ID);
report('layer load stats', loaded);
for (const id of [...INFRA_LAYER_IDS, CABLE_LAYER_ID]) {
check(`${id} loaded its bundled dataset`, (loaded[id]?.count || 0) > 0, loaded[id]);
}
// Nudge a render pass so the first post-enable LOD walk runs.
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
await new Promise((r) => setTimeout(r, 2_000));
}
// ── GATE a: full-earth budget engaged ─────────────────────────────────
const readLod = (label) => page.evaluate((infraIds) => {
const gev = window.__godsEyeView;
const out = {};
for (const id of infraIds) {
out[id] = gev.dataManager.layers.get(id)?.module?.getLodDiagnostics?.() || null;
}
return out;
}, INFRA_LAYER_IDS).then((lod) => { report(label, lod); return lod; });
if (!CONTROL) {
const globalLod = await readLod('getLodDiagnostics @ full-earth');
for (const id of INFRA_LAYER_IDS) {
const d = globalLod[id];
check(`${id}: LOD selection has run`, !!d && d.computed === true, d);
check(`${id}: active stems within the band budget`, !!d && d.active <= d.budgetLimit, d);
check(`${id}: full-earth band budget is INFRA_LOD_ACTIVE_MIN`,
!!d && d.budgetLimit === INFRA_LOD_ACTIVE_MIN, { got: d?.budgetLimit, want: INFRA_LOD_ACTIVE_MIN });
// Every bound above is satisfied by an EMPTY active set: `computed` is
// true, `0 <= budgetLimit`, and `0 < total`. A regressed candidate build
// or occlusion test would therefore pass the gate while both layers
// render nothing, so assert the selection is actually populated.
check(`${id}: active set is non-empty`, !!d && d.active > 0, d);
// "declutter engaged" only asserts when the dataset actually exceeds the
// cap — a tiny dataset legitimately shows every feature. This fixture is
// a full-earth view of a global dataset an order of magnitude past the
// global band, so the only correct outcome is a cap-filled set.
if (d && d.total > d.budgetLimit) {
check(`${id}: declutter is engaged (active < total)`, d.active < d.total, d);
check(`${id}: full-earth active set fills the band budget`,
d.active === d.budgetLimit, { active: d.active, budgetLimit: d.budgetLimit });
}
}
// ── GATE b: zoom in widens the budget ───────────────────────────────
await page.evaluate(() => {
const v = window.__godsEyeView.viewer;
const ell = v.scene.globe.ellipsoid;
v.camera.cancelFlight();
v.camera.setView({
destination: ell.cartographicToCartesian({
longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 55_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
});
// setView does not fire moveEnd; raise the LOD recompute trigger.
v.camera.moveEnd.raiseEvent?.();
v.scene.requestRender?.();
});
await new Promise((r) => setTimeout(r, 3_000));
const regionalLod = await readLod('getLodDiagnostics @ city');
for (const id of INFRA_LAYER_IDS) {
const g = globalLod[id];
const r = regionalLod[id];
check(`${id}: city band lifts the budget to INFRA_LOD_ACTIVE_MAX`,
!!r && r.budgetLimit === INFRA_LOD_ACTIVE_MAX, { got: r?.budgetLimit, want: INFRA_LOD_ACTIVE_MAX });
// `>=` alone accepts 0 >= 0, so the non-shrink check needs a floor too.
check(`${id}: active set did not shrink when zooming in`,
!!r && !!g && r.active > 0 && r.active >= Math.min(g.active, r.total),
{ global: g?.active, regional: r?.active });
}
// ── GATE c: no churn between camera moves ───────────────────────────
const churn = await page.evaluate(async (infraIds) => {
const gev = window.__godsEyeView;
const snap = () => infraIds.map((id) => {
const visibleIds = [];
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
for (const entity of gev.viewer.dataSources.get(i).entities.values) {
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id));
}
}
return visibleIds.sort();
});
const before = snap();
for (let i = 0; i < 5; i++) {
gev.viewer.scene.requestRender?.();
await new Promise((r) => setTimeout(r, 120));
}
return { before, after: snap() };
}, INFRA_LAYER_IDS);
check('the same visible IDs remain stable without a camera move',
churn.before.every((ids) => ids.length > 0)
&& JSON.stringify(churn.before) === JSON.stringify(churn.after),
{ beforeCounts: churn.before.map((ids) => ids.length), afterCounts: churn.after.map((ids) => ids.length) });
// Back to full-earth for the frame-cost measurement.
await page.evaluate(() => {
const v = window.__godsEyeView.viewer;
const ell = v.scene.globe.ellipsoid;
v.camera.setView({
destination: ell.cartographicToCartesian({
longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
v.camera.moveEnd.raiseEvent?.();
v.scene.requestRender?.();
});
await new Promise((r) => setTimeout(r, 3_000));
}
// Count actual rendered frames: scene.render() can return without drawing.
const frameCost = await page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
const scene = v.scene;
const durations = [];
let frames = 0;
let previous;
const remove = scene.postRender.addEventListener(() => {
const now = performance.now();
if (previous !== undefined) durations.push(now - previous);
previous = now;
frames++;
});
const t0 = performance.now();
const tick = () => {
v.camera.rotateRight(0.0004);
scene.requestRender();
if (performance.now() - t0 < 10_000) requestAnimationFrame(tick);
else {
remove();
const elapsedMs = performance.now() - t0;
durations.sort((a, b) => a - b);
const n = durations.length;
const sum = durations.reduce((s, d) => s + d, 0);
resolve({
frames,
elapsedMs: +elapsedMs.toFixed(1),
meanMs: +(sum / Math.max(1, n)).toFixed(2),
p50Ms: +(durations[Math.floor(n * 0.5)] || 0).toFixed(2),
p95Ms: +(durations[Math.floor(n * 0.95)] || 0).toFixed(2),
maxMs: +(durations[n - 1] || 0).toFixed(2),
effectiveFps: +(frames * 1000 / elapsedMs).toFixed(1),
});
}
};
requestAnimationFrame(tick);
}));
report(CONTROL
? 'rendered-frame intervals, control (infra OFF), 10s orbit'
: 'rendered-frame intervals, infra ON, 10s orbit', frameCost);
// ── parked-idle honesty: the LOD walk must not force continuous render ─
await new Promise((r) => setTimeout(r, 4_000));
const idle = await page.evaluate(async () => {
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
return new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
const startedAt = Date.now();
const tilesLoadedBefore = scene.globe.tilesLoaded;
let renders = 0;
let framesWithPendingTiles = 0;
const remove = scene.postRender.addEventListener(() => {
renders += 1;
if (!scene.globe.tilesLoaded) framesWithPendingTiles += 1;
});
setTimeout(() => {
remove();
const governor = getRenderGovernorDiagnostics();
resolve({ renders, tilesLoadedBefore, tilesLoadedAfter: scene.globe.tilesLoaded,
framesWithPendingTiles, mode: governor.mode, holds: governor.holds,
requests: governor.recentRequests.filter(request => request.at >= startedAt) });
}, 5_000);
});
});
report(CONTROL
? 'parked idle, control (postRender fires / 5s)'
: 'parked idle with infra ON (postRender fires / 5s)', idle);
if (!CONTROL) {
check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle);
}
check('no uncaught application errors', pageErrors.length === 0, pageErrors);
try {
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const shot = path.join(SHOTS_DIR, CONTROL ? 'infra-lod-control.png' : 'infra-lod.png');
await page.screenshot({ path: shot });
console.log(` [SHOT] ${shot}`);
} catch (e) {
console.log(` [SHOT] skipped — ${e?.message || e}`);
}
} finally {
await browser.close();
}
const passed = results.filter((r) => r.pass).length;
console.log(`\nqa-infra-lod: ${passed}/${results.length} passed${CONTROL ? ' (control mode — gates skipped)' : ''}`);
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
process.exit(passed === results.length ? 0 : 1);
+1 -1
View File
@@ -1205,7 +1205,7 @@ async function runBrowserGroup(record) {
const emit = (id, res, ms) => { if (ids.includes(id)) record(CHECKS.find((c) => c.id === id), res, ms); };
const only = (id) => ids.includes(id);
const exe = (() => { try { return puppeteer.executablePath(); } catch { return null; } })();
const exe = await puppeteer.executablePath().catch(() => null);
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(exe ? { executablePath: exe } : {}),
+1 -1
View File
@@ -157,7 +157,7 @@ const CHROME_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Pin puppeteer's Chrome-for-Testing: system Chrome auto-updates underneath
// the harnesses and its software-GL behaviour shifts across majors.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
].filter(Boolean);
function findChrome() {
+1 -1
View File
@@ -56,7 +56,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
/** Browser proof of Layers panel replacement and subscription ownership. */
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
let failures = 0;
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const check = (name, passed) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
};
try {
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() => window.__godsEyeView?.dataManager?._layerPanel,
{ timeout: 60000 },
);
const results = await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
const container = manager._toggleContainer;
const id = 'qa-panel-lifecycle';
let listener = null;
let enabled = 0;
window.__gevQaRegisterLayer(manager, {
id,
name: '<b>Literal layer</b>',
icon: '◌',
source: 'Local fixture',
updateInterval: -1,
init() {},
enable() {
enabled++;
},
disable() {},
update() {},
destroy() {},
getStats: () => ({ count: 1250, lastUpdate: Date.now(), stale: true }),
getRowControls: () => ({ chips: [] }),
setRowControlsListener: (callback) => {
listener = callback;
},
});
const result = [];
try {
manager.buildTogglePanel(container);
const row = () => container.querySelector(`[data-layer-id="${id}"]`);
const first = row().querySelector('.data-toggle-btn');
result.push([
'descriptor text is literal content',
row().querySelector('.data-name').textContent ===
'<b>Literal layer</b>' && !row().querySelector('b'),
]);
result.push([
'row subscription is installed',
typeof listener === 'function',
]);
manager.buildTogglePanel(container);
first.click();
await Promise.resolve();
result.push([
'a detached row cannot issue an enable action',
enabled === 0 && !manager.isEnabled(id),
]);
await manager.setEnabled(id, true);
result.push([
'count is presented from the supplied snapshot',
row().querySelector('.data-count').textContent === '1.3K',
]);
result.push([
'feed state reflects the settled layer snapshot',
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
]);
const ui = window.__godsEyeView.styleManager;
ui._clearSelectedLayersBtn.click();
result.push([
'native clear activation presents busy state',
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
!!ui._contextControls._clearSelectedLayersPromise,
]);
await ui._contextControls._clearSelectedLayersPromise;
result.push([
'clear settles through the existing layer transaction',
!manager.isEnabled(id) &&
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
]);
await manager.setEnabled(id, true);
const final = row().querySelector('.data-toggle-btn');
manager._layerPanel.destroy();
final.click();
await Promise.resolve();
result.push([
'destroyed panel releases row subscriptions',
listener === null,
]);
result.push([
'destroyed controls cannot change the layer',
manager.isEnabled(id),
]);
} finally {
manager._layerPanel?.destroy();
manager._layerPanel = null;
await window.__gevQaUnregisterLayer(manager, id);
manager.buildTogglePanel(container);
}
result.push([
'ordinary layer rows return after reassembly',
container.querySelectorAll('.data-toggle-row').length > 10,
]);
return result;
});
for (const [name, passed] of results) check(name, passed);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env node
/** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
let failures = 0;
const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._locationControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.click('#location-bar-toggle');
await page.click('[data-pin-target="location-bar"]');
const cityId = await page.$eval(
'.location-pill',
(node) => node.dataset.locationId,
);
await page.click('.location-pill');
await page.waitForFunction(() =>
document.getElementById('poi-row').classList.contains('expanded'),
);
check(
'native city selection highlights and expands its POIs',
await page.evaluate(
(id) =>
window.__godsEyeView.styleManager._activeLocationId === id &&
document.querySelector('.location-pill.active')?.dataset.locationId ===
id,
cityId,
),
);
await page.keyboard.press('w');
check(
'POI keyboard action selects the second landmark',
await page.evaluate(
() =>
window.__godsEyeView.styleManager._activePoiIndex === 1 &&
document.querySelector('.poi-pill.active')?.dataset.poiIndex === '1',
),
);
check(
'orbit controls and indicator agree',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
ui.setOrbit(true);
const active =
ui.orbitController.active &&
document.getElementById('orbit-indicator').classList.contains('active');
ui.setOrbit(false);
return (
active &&
!ui.orbitController.active &&
!document.getElementById('orbit-indicator').classList.contains('active')
);
}),
);
await page.click('#search-toggle');
await page.type('#location-search', 'qwerty');
check(
'typing in search leaves POI selection unchanged',
await page.evaluate(
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
),
);
await page.evaluate(() => {
const lookup = window.__godsEyeView.styleManager._locationLookup;
window.__qaSearchRequests = [];
window.__qaLocationState = [];
window.__godsEyeView.styleManager.subscribeLocationSearch((notification) =>
window.__qaLocationState.push(notification),
);
lookup.search = (query, options) =>
new Promise((resolve) =>
window.__qaSearchRequests.push({ query, options, resolve }),
);
document.getElementById('location-search').value = 'First';
});
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
await page.evaluate(() => {
document.getElementById('location-search').value = 'Second';
document.getElementById('location-search').focus();
});
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__qaSearchRequests.length === 2);
check(
'second native submit aborts the first request and keeps current busy state',
await page.evaluate(
() =>
window.__qaSearchRequests[0].options.signal.aborted &&
document
.getElementById('location-search')
.classList.contains('searching'),
),
);
await page.evaluate(() =>
window.__qaSearchRequests[1].resolve({
label: 'Second landmark, Test city',
}),
);
await page.waitForFunction(
() =>
window.__godsEyeView.styleManager._searchedLocationLabel ===
'Second landmark, Test city',
);
await page.evaluate(() =>
window.__qaSearchRequests[0].resolve({
label: 'First landmark, Test city',
}),
);
check(
'late result cannot overwrite the current location readout or style',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === 'Second landmark, Test city' &&
ui._locationMiniCity.textContent.includes('Second landmark') &&
ui._locationMiniPoi.textContent === 'Test city' &&
ui.activeStyle === 'normal' &&
!ui._locationSearch.classList.contains('searching')
);
}),
);
check(
'Location subscriptions accept only the current result and retain request identities',
await page.evaluate(() => {
const seen = window.__qaLocationState;
const started = seen.filter(({ change }) => change?.type === 'started');
const found = seen.filter(({ change }) => change?.type === 'found');
return (
seen[0].initial &&
started.length === 2 &&
found.length === 1 &&
found[0].change.requestId === started[1].change.requestId &&
found[0].state.destination.label === 'Second landmark, Test city' &&
Object.isFrozen(found[0].state.destination)
);
}),
);
check(
'cancelled POI expansion stays closed after a frame',
await page.evaluate(async (id) => {
const ui = window.__godsEyeView.styleManager;
ui._expandPOIRow(id);
ui._collapsePOIRow();
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return (
!ui._poiRow.classList.contains('expanded') &&
!ui._locationBarDivider.classList.contains('visible')
);
}, cityId),
);
await page.evaluate(
(id) => window.__godsEyeView.styleManager._onCityPillClick(id),
cityId,
);
await page.waitForFunction(() =>
document.getElementById('poi-row').classList.contains('expanded'),
);
// Finish the camera's existing city flight before inspecting both layouts.
await page.waitForFunction(
() => !window.__godsEyeView.viewer.camera._currentFlight,
{ timeout: 10000 },
);
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
const settled = await page
.waitForFunction(
() => {
const scene = window.__godsEyeView.viewer.scene;
for (let index = 0; index < scene.primitives.length; index++) {
const primitive = scene.primitives.get(index);
if (
typeof primitive.tilesLoaded === 'boolean' &&
!primitive.tilesLoaded
)
return false;
}
return true;
},
{ timeout: 60000 },
)
.then(
() => true,
() => false,
);
check('visible city tiles settle before screenshots', settled);
fs.mkdirSync('qa-shots/location-controls', { recursive: true });
await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
await page.waitForFunction(
() =>
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
'mobile',
);
await page.screenshot({ path: 'qa-shots/location-controls/narrow.png' });
check(
'native reset returns through the existing camera handoff',
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
ui._resetGlobeBtn.click();
const pending = ui._globeResetPromise;
if (!pending) return false;
await pending;
return (
!ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity
);
}),
);
check(
'destroyed controls cannot navigate or submit another lookup',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
const count = window.__qaSearchRequests.length;
ui._locationControls.destroy();
ui._locationLookup.destroy();
const before = ui._navigationGeneration;
ui._locationPills.querySelector('button').click();
ui._resetGlobeBtn.click();
ui._locationSearch.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
return (
before === ui._navigationGeneration &&
count === window.__qaSearchRequests.length
);
}),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
/** Browser acceptance for Map Source control lifecycle and truthful selection. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
window.__godsEyeView?.styleManager?._mapSourceControls &&
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const controller = manager.mapStackController;
const original = controller.setStack;
window.__qaMapSelections = [];
controller.setStack = function (id) {
window.__qaMapSelections.push(id);
return original.call(this, id);
};
manager.setPanelCollapsed('control-panel', false, {
persist: false,
syncShare: false,
});
});
await page.click('[data-stack-id="osm"]');
await page.waitForFunction(
() =>
window.__godsEyeView.styleManager.mapStackController.getActiveId() ===
'osm',
);
check(
'a native chip click requests OSM exactly once',
await page.evaluate(
() =>
window.__qaMapSelections.length === 1 &&
window.__qaMapSelections[0] === 'osm',
),
);
check(
'active chip and status follow the displayed OSM source',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
return (
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === 'osm' &&
manager._mapStackStatus.textContent ===
manager.mapStackController.getState().activeStack.shortLabel
);
}),
);
check(
'the public map action uses the same component and truthful result',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const result = await manager.setMapStack('esri-imagery');
const active = manager.mapStackController.getActiveId();
return (
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === active &&
(result.ok || !!manager.mapStackController.getState().lastError)
);
}),
);
check(
'refresh revokes retained old chips',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
manager._mapSourceControls.refresh();
const before = window.__qaMapSelections.length;
old.click();
return window.__qaMapSelections.length === before;
}),
);
check(
'refreshed chips still select once',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
await Promise.resolve();
return window.__qaMapSelections.length === before + 1;
}),
);
await page.waitForFunction(
() =>
window.__godsEyeView.styleManager.mapStackController.getActiveId() ===
'osm',
);
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
await manager.setMapStack('photoreal');
manager.setPanelCollapsed('control-panel', false, {
persist: false,
syncShare: false,
});
document.querySelector('.map-stack-chip.active')?.focus();
});
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.scene.tweens?.removeAll?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (-97.7431 * Math.PI) / 180,
latitude: (30.2568 * Math.PI) / 180,
height: 1200,
}),
orientation: { heading: 0, pitch: -0.85, roll: 0 },
});
viewer.scene.requestRender();
});
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
const settled = await page
.waitForFunction(
() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
for (let i = 0; i < primitives.length; i++) {
const primitive = primitives.get(i);
if (
typeof primitive.tilesLoaded === 'boolean' &&
!primitive.tilesLoaded
)
return false;
}
return true;
},
{ timeout: 60_000 },
)
.then(
() => true,
() => false,
);
check('visible tile content settles before visual captures', settled);
fs.mkdirSync('qa-shots/map-source-controls', { recursive: true });
await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
await page.waitForFunction(
() =>
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
'mobile',
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed(
'control-panel',
false,
{ persist: false, syncShare: false },
),
);
await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' });
check(
'destroyed controls cannot issue requests from retained chips',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapSourceControls.destroy();
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
const result = await manager._mapSourceControls.select('esri-imagery');
return result === null && window.__qaMapSelections.length === before;
}),
);
check('no uncaught browser errors', errors.length === 0);
if (errors.length) console.log(JSON.stringify(errors));
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
File diff suppressed because it is too large. Load diff
+262 -141
View File
@@ -2,7 +2,8 @@
/**
* Deterministic browser proof for the Radio companion layer.
*
* Intercepts only `/api/radio/*` with a 750-station fixture and stubs the
* Intercepts Radio endpoints with a 750-station fixture, supplies fixed
* satellite/context support responses, and stubs the
* browser media `play()` primitive. It proves marker scale/culling, dynamic
* station-tag filtering, first-click selection, direct-action-only playback, panel/Context
* independence, restoration without autoplay, responsive UI, and a clean
@@ -25,11 +26,15 @@ const option = (name, fallback) => {
};
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
const APP_ORIGIN = new URL(APP_URL).origin;
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
// has its own acceptance harness and must not intercept those gestures.
const RADIO_URL = new URL(APP_URL);
RADIO_URL.searchParams.set('welcome', '0');
const HEADFUL = args.includes('--headful');
const chromeCandidates = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
@@ -76,6 +81,22 @@ function check(name, ok, detail = '') {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Native pick buffers and clustered overlay entries settle on rendered frames,
// not on wall-clock sleeps, particularly with software rendering.
async function settleRadioFrames(page) {
await page.evaluate(() => new Promise((resolve, reject) => {
const scene = window.__godsEyeView.viewer.scene;
let remaining = 4;
const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000);
const remove = scene.postRender.addEventListener(() => {
if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); }
else requestAnimationFrame(() => scene.requestRender());
});
scene.requestRender();
}));
}
async function main() {
const response = await fetch(APP_URL).catch(() => null);
if (!response?.ok) {
@@ -87,7 +108,10 @@ async function main() {
headless: HEADFUL ? false : 'new',
...(chrome ? { executablePath: chrome } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle',
'--no-sandbox', '--disable-setuid-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--window-size=1440,900',
],
@@ -142,6 +166,28 @@ async function main() {
let catalogResponseDelayMs = 0;
page.on('request', (request) => {
const url = new URL(request.url());
// These scenarios exercise Context lifecycle and keyboard ownership, not
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
// CelesTrak outages cannot invalidate an otherwise clean UI run.
if (url.origin === APP_ORIGIN
&& ['/api/celestrak/active', '/api/celestrak/starlink'].includes(url.pathname)) {
const dense = url.pathname.endsWith('/starlink');
request.respond({
status: 200,
contentType: 'text/plain',
body: (dense ? [
'STARLINK-1007',
'1 44713U 19074A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
'2 44713 53.0000 247.4627 0006703 130.5360 325.0288 15.06000000 12345',
] : [
'ISS (ZARYA)',
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49814310 12345',
]).join('\n') + '\n',
});
return;
}
if (url.origin === APP_ORIGIN && url.pathname === '/api/radio/stations') {
const response = {
status: 200,
@@ -213,7 +259,7 @@ async function main() {
}
});
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 });
await page.waitForFunction(
@@ -281,7 +327,7 @@ async function main() {
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
tunerVisible: !document.getElementById('radio-tuner').hidden,
tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length,
tunerCount: window.__godsEyeView.styleManager._radioControls._radioTunerStations.length,
tunerLabel: document.getElementById('radio-tuner-band-label').textContent,
};
});
@@ -332,19 +378,19 @@ async function main() {
const staticResult = module.setTuningStatic(true);
const filterBefore = module.getUIState().filter;
const filterControl = document.getElementById('radio-filter');
const originalPinned = style._radioTunerBandPinnedForNavigation;
const originalPool = style._radioTunerPool;
style._radioTunerBandPinnedForNavigation = true;
const originalPinned = style._radioControls._radioTunerBandPinnedForNavigation;
const originalPool = style._radioControls._radioTunerPool;
style._radioControls._radioTunerBandPinnedForNavigation = true;
filterControl.value = filterBefore === 'news' ? 'all' : 'news';
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
const rejectedUIFilter = {
moduleFilter: module.getUIState().filter,
controlFilter: filterControl.value,
pinned: style._radioTunerBandPinnedForNavigation,
poolIdentityPreserved: style._radioTunerPool === originalPool,
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
poolIdentityPreserved: style._radioControls._radioTunerPool === originalPool,
};
style._radioTunerBandPinnedForNavigation = originalPinned;
style._radioTunerPool = originalPool;
style._radioControls._radioTunerBandPinnedForNavigation = originalPinned;
style._radioControls._radioTunerPool = originalPool;
const filterResult = module.setFilter('news');
const toggleResult = await module.togglePlayback({ origin: 'user' });
const directSelect = module.selectStation(stationId, {
@@ -571,7 +617,14 @@ async function main() {
await page.evaluate(() => window.__qaReleaseRadioEnable?.());
await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio'));
await page.evaluate(() => window.__qaRestoreRadioEnable?.());
await sleep(650);
await page.waitForFunction(() => {
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
const viewport = scroller.getBoundingClientRect();
const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect();
const play = document.getElementById('radio-play-btn').getBoundingClientRect();
return directory.top >= viewport.top && directory.bottom <= viewport.bottom
&& play.top >= viewport.top && play.bottom <= viewport.bottom;
}, { timeout: 10_000 });
const explicitRevealAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
@@ -638,6 +691,7 @@ async function main() {
viewer.scene.requestRender();
}, { lon: spec.lon, height: view.height });
await sleep(700);
await settleRadioFrames(page);
const sample = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
@@ -729,10 +783,10 @@ async function main() {
const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds;
const originalIsEnabled = dataManager.isEnabled;
const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled;
const originalClear = styleManager._clearLayersOutsideContextMode;
const originalCapture = styleManager._captureContextSessionSnapshot;
const originalContextMode = styleManager._contextMode;
const originalSnapshot = styleManager._contextSessionSnapshot;
const originalClear = styleManager._contextControls._clearLayersOutsideContextMode;
const originalCapture = styleManager._contextControls._captureContextSessionSnapshot;
const originalContextMode = styleManager._contextControls._contextMode;
const originalSnapshot = styleManager._contextControls._contextSessionSnapshot;
const originalShowToast = styleManager._showToast;
const installations = dataManager.layers.get('military-installations')?.module;
const originalSearchNearby = installations?.searchNearby;
@@ -743,12 +797,12 @@ async function main() {
const semanticFalseMessage = document.getElementById('toast').textContent;
const enabled = new Set();
styleManager._captureContextSessionSnapshot = () => {};
styleManager._contextControls._captureContextSessionSnapshot = () => {};
const runContextFailure = async (mode, phase, outcome) => {
enabled.clear();
enabled.add('local-datacenters');
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(enabled),
userAdded: new Set(),
userRemoved: new Set(),
@@ -768,7 +822,7 @@ async function main() {
return true;
};
const result = await styleManager._runUserFacingContextAction(
() => styleManager._selectContextMode(mode),
() => styleManager._contextControls._selectContextMode(mode),
`${mode} QA transition failed`,
);
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -777,8 +831,8 @@ async function main() {
phase,
outcome,
result,
visibleMode: styleManager._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null,
visibleMode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored: enabled.has('local-datacenters'),
entryEnabled: enabled.has(entryLayerId),
flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled,
@@ -816,8 +870,8 @@ async function main() {
for (const outcome of ['false', 'reject']) {
enabled.clear();
enabled.add('local-datacenters');
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(enabled),
userAdded: new Set(),
userRemoved: new Set(),
@@ -832,7 +886,7 @@ async function main() {
dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => {
if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) {
failureUsed = true;
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed',
layerId: candidateId,
enabled: shouldEnable,
@@ -851,7 +905,7 @@ async function main() {
enabled: true,
origin: 'user',
});
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-blocked',
layerId,
enabled: true,
@@ -871,10 +925,10 @@ async function main() {
reason,
retryReason,
toastMessages,
mode: styleManager._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null,
mode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored,
modeChanging: styleManager._contextModeChanging,
modeChanging: styleManager._contextControls._contextModeChanging,
});
}
}
@@ -883,8 +937,8 @@ async function main() {
const directActivationFailures = [];
for (const layerId of ['military-awareness', 'rocket-launches']) {
enabled.clear();
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(['local-datacenters']),
userAdded: new Set(),
userRemoved: new Set(),
@@ -894,7 +948,7 @@ async function main() {
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
return true;
};
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed',
layerId,
enabled: true,
@@ -903,8 +957,8 @@ async function main() {
await new Promise((resolve) => setTimeout(resolve, 50));
directActivationFailures.push({
layerId,
mode: styleManager._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null,
mode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored: enabled.has('local-datacenters'),
failedLayerDisabled: !enabled.has(layerId),
});
@@ -913,8 +967,8 @@ async function main() {
const directActivationRollbackFailures = [];
for (const layerId of ['military-awareness', 'rocket-launches']) {
enabled.clear();
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(['local-datacenters']),
userAdded: new Set(),
userRemoved: new Set(),
@@ -931,7 +985,7 @@ async function main() {
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
return true;
};
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed',
layerId,
enabled: true,
@@ -942,7 +996,7 @@ async function main() {
directActivationRollbackFailures.push({
layerId,
toastMessages,
retryRetained: styleManager._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
retryRetained: styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
});
}
@@ -972,8 +1026,8 @@ async function main() {
window.__gevQaRegisterLayer(dataManager, qaLayer);
try {
const enabledBeforeExit = dataManager.getEnabledLayerIds();
styleManager._contextMode = 'flights';
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = 'flights';
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set([...enabledBeforeExit, layerId]),
userAdded: new Set(),
userRemoved: new Set(),
@@ -985,13 +1039,13 @@ async function main() {
};
const unhandledBefore = unhandled.length;
const result = await styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
`QA Context exit ${outcome} surfaced once`,
);
const retainedForRetry = styleManager._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
const retainedForRetry = styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
enableOutcome = 'success';
const retryResult = await styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
`QA Context exit ${outcome} retry failed`,
);
contextExitFailures.push({
@@ -1001,9 +1055,9 @@ async function main() {
toastMessages,
unhandledDelta: unhandled.length - unhandledBefore,
retainedForRetry,
mode: styleManager._contextMode,
modeChanging: styleManager._contextModeChanging,
snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null,
mode: styleManager._contextControls._contextMode,
modeChanging: styleManager._contextControls._contextModeChanging,
snapshotClearedAfterRetry: styleManager._contextControls._contextSessionSnapshot === null,
enabledAfterRetry: dataManager.isEnabled(layerId),
toastRole: document.getElementById('toast').getAttribute('role'),
toastLive: document.getElementById('toast').getAttribute('aria-live'),
@@ -1109,10 +1163,10 @@ async function main() {
dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds;
dataManager.isEnabled = originalIsEnabled;
dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled;
styleManager._clearLayersOutsideContextMode = originalClear;
styleManager._captureContextSessionSnapshot = originalCapture;
styleManager._contextMode = originalContextMode;
styleManager._contextSessionSnapshot = originalSnapshot;
styleManager._contextControls._clearLayersOutsideContextMode = originalClear;
styleManager._contextControls._captureContextSessionSnapshot = originalCapture;
styleManager._contextControls._contextMode = originalContextMode;
styleManager._contextControls._contextSessionSnapshot = originalSnapshot;
styleManager._showToast = originalShowToast;
if (installations) installations.searchNearby = originalSearchNearby;
window.removeEventListener('unhandledrejection', onUnhandled);
@@ -1320,12 +1374,9 @@ async function main() {
&& clusterBadge?.rect?.h > 0,
JSON.stringify(clusterBadge),
);
const highGlobalClusterLabels = await page.evaluate(async () => {
await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
@@ -1337,6 +1388,14 @@ async function main() {
});
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 500));
});
await settleRadioFrames(page);
const highGlobalClusterLabels = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
const source = radio.getOverlayDiagnostics();
const cameraPosition = viewer.camera.positionWC;
const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z);
@@ -1347,11 +1406,23 @@ async function main() {
const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection;
let clusterPoint = null;
// Pool order is not visibility order after a camera change. Choose an
// on-screen point independently of the native pick result being tested.
const visiblePoints = [];
for (let index = 0; index < (points?.length || 0); index += 1) {
const point = points.get(index);
if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue;
const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position);
if (!anchor) continue;
if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue;
visiblePoints.push({ point, anchor });
}
visiblePoints.sort((a, b) => (
Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2)
- Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2)
));
if (visiblePoints.length) {
const { point, anchor } = visiblePoints[0];
const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => (
picked?.primitive === point && picked?.id === point.id
));
@@ -1359,7 +1430,6 @@ async function main() {
maxDistance: point.distanceDisplayCondition?.far ?? null,
pickable: Boolean(exactPick),
};
break;
}
return {
source,
@@ -1632,8 +1702,8 @@ async function main() {
dataset: { ...stack.dataset },
}];
})),
preferredLeftPanelId: manager._leftStackPreferredPanelId,
preferredRightPanelId: manager._rightStackPreferredPanelId,
preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId,
preferredRightPanelId: manager._panelLayout._rightStackPreferredPanelId,
hudMode: manager.hud.getMode(),
hudVariant: manager.hud.getVariant(),
focusId: document.activeElement?.id || null,
@@ -1697,7 +1767,7 @@ async function main() {
const scenePanel = document.getElementById('scene-panel');
const contextPanel = document.getElementById('global-context-panel');
result.tacticalAutoCollapse = {
latestOpenedPanel: manager._leftStackPreferredPanelId,
latestOpenedPanel: manager._panelLayout._leftStackPreferredPanelId,
scenesExpanded: !scenePanel.classList.contains('collapsed'),
dataCollapsed: dataPanel.classList.contains('layout-auto-collapsed'),
dataHiddenWhileScenesOwnsLane: dataPanel.getBoundingClientRect().height === 0
@@ -1744,13 +1814,13 @@ async function main() {
manager.hud.setMode(prior.hudMode);
manager._updateHudButtonState();
await new Promise((resolve) => setTimeout(resolve, 320));
if (manager._leftStackLayoutFrame !== null) {
cancelAnimationFrame(manager._leftStackLayoutFrame);
manager._leftStackLayoutFrame = null;
if (manager._panelLayout._leftStackLayoutFrame !== null) {
cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
manager._panelLayout._leftStackLayoutFrame = null;
}
if (manager._rightStackLayoutFrame !== null) {
cancelAnimationFrame(manager._rightStackLayoutFrame);
manager._rightStackLayoutFrame = null;
if (manager._panelLayout._rightStackLayoutFrame !== null) {
cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
manager._panelLayout._rightStackLayoutFrame = null;
}
for (const [id, state] of Object.entries(prior.panels)) {
const panel = document.getElementById(id);
@@ -1769,18 +1839,18 @@ async function main() {
for (const key of Object.keys(stack.dataset)) delete stack.dataset[key];
Object.assign(stack.dataset, state.dataset);
}
manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
manager._rightStackPreferredPanelId = prior.preferredRightPanelId;
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
manager._panelLayout._rightStackPreferredPanelId = prior.preferredRightPanelId;
localStorage.clear();
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (manager._leftStackLayoutFrame !== null) {
cancelAnimationFrame(manager._leftStackLayoutFrame);
manager._leftStackLayoutFrame = null;
if (manager._panelLayout._leftStackLayoutFrame !== null) {
cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
manager._panelLayout._leftStackLayoutFrame = null;
}
if (manager._rightStackLayoutFrame !== null) {
cancelAnimationFrame(manager._rightStackLayoutFrame);
manager._rightStackLayoutFrame = null;
if (manager._panelLayout._rightStackLayoutFrame !== null) {
cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
manager._panelLayout._rightStackLayoutFrame = null;
}
// Disclosure restoration can wake the installed layout observers. Let
// those observers settle, then restore the captured stack presentation
@@ -1810,8 +1880,8 @@ async function main() {
result.restoreDetails = {
panelsRestored,
stacksRestored,
preferredPanelRestored: manager._leftStackPreferredPanelId === prior.preferredLeftPanelId,
preferredRightPanelRestored: manager._rightStackPreferredPanelId === prior.preferredRightPanelId,
preferredPanelRestored: manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId,
preferredRightPanelRestored: manager._panelLayout._rightStackPreferredPanelId === prior.preferredRightPanelId,
hudModeRestored: manager.hud.getMode() === prior.hudMode,
hudVariantRestored: manager.hud.getVariant() === prior.hudVariant,
storageRestored: JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage),
@@ -1875,7 +1945,10 @@ async function main() {
hudVariant: manager.hud.getVariant(),
focusId: document.activeElement?.id || null,
};
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
const waitForLayout = async () => {
await new Promise((resolve) => setTimeout(resolve, 320));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const cases = [];
manager.setPanelCollapsed('global-context-panel', true);
@@ -1952,7 +2025,25 @@ async function main() {
// owns the real tracked-aircraft camera session. Hold the frame update so
// the intentionally synthetic Cockpit shell is not auto-exited mid-check.
const realCockpitUpdate = manager.cockpitView.update;
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
const intelHud = document.getElementById('intel-hud');
const priorHudTransition = intelHud.style.getPropertyValue('transition');
const priorHudTransitionPriority = intelHud.style.getPropertyPriority('transition');
const waitForLayout = async () => {
await new Promise((resolve) => setTimeout(resolve, 320));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const waitForHudSettle = async () => {
await new Promise((resolve) => setTimeout(resolve, 560));
// Let panel changes settle. qa-cockpit-utility covers scheduling with
// the real tracked-aircraft controller; this synthetic shell pauses it.
intelHud.getBoundingClientRect();
// This fixture pauses the controller update. Run its actual layout
// methods against the settled HUD, then measure both unchanged lanes.
manager.cockpitView.syncContextLayout();
manager.cockpitView.syncSignalLayout();
manager._syncLeftPanelAdaptiveLayout();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const prior = {
cockpit: document.body.classList.contains('cockpit-mode'),
cockpitActive: manager.cockpitView.active,
@@ -1971,7 +2062,7 @@ async function main() {
detectionMode: manager.getDetectionState().detectionMode,
models3dEnabled: manager._models3dEnabled,
models3dMode: manager._models3dMode,
preferredLeftPanelId: manager._leftStackPreferredPanelId,
preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId,
panels: Object.fromEntries([
'data-panel',
'global-context-panel',
@@ -1989,6 +2080,9 @@ async function main() {
let result = {};
try {
manager.cockpitView.update = () => {};
// This block measures settled panel geometry, not fade timing. The
// synthetic paused controller cannot advance layout during a HUD fade.
intelHud.style.setProperty('transition', 'none', 'important');
document.body.classList.add('cockpit-mode');
manager.cockpitView.active = true;
manager._setCockpitDisplayPortalActive(true);
@@ -2002,7 +2096,7 @@ async function main() {
manager._setHudVariant('tactical');
manager.hud.setMode('on');
manager._updateHudButtonState();
await waitForLayout();
await waitForHudSettle();
const layoutSteps = [];
// The two Cockpit lanes are solved independently: the accordion against
@@ -2055,9 +2149,7 @@ async function main() {
layoutMode: document.getElementById('left-panel-stack').dataset.layoutMode,
});
};
// The Intel HUD fades over 400ms and keeps its readout rects for the
// whole transition, so both lanes are measured only once it has settled.
const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560));
// Both lanes are measured against the settled HUD variant.
for (let index = 0; index < 5; index += 1) {
recordLayoutStep();
if (index < 4) {
@@ -2208,19 +2300,23 @@ async function main() {
const stableTop = utility.getBoundingClientRect().top;
const transitionTops = [];
let sampling = true;
let sampleFrame = null;
const sampleTop = () => {
if (!sampling) return;
transitionTops.push(utility.getBoundingClientRect().top);
if (sampling) requestAnimationFrame(sampleTop);
sampleFrame = requestAnimationFrame(sampleTop);
};
requestAnimationFrame(sampleTop);
sampleFrame = requestAnimationFrame(sampleTop);
await manager._setMapStack('osm', { syncShare: false });
await waitForLayout();
sampling = false;
cancelAnimationFrame(sampleFrame);
mapProviderUtilityStable = transitionTops.length > 1
&& transitionTops.every((top) => Math.abs(top - stableTop) < 1);
}
result = {
cockpitPanelInteraction,
layoutSteps,
displayOpened,
sharedDisplayControlsPortaled,
radioOpened,
@@ -2269,6 +2365,8 @@ async function main() {
manager._setModels3dEnabled(prior.models3dEnabled);
manager._setHudVariant(prior.hudVariant);
manager.hud.setMode(prior.hudMode);
if (priorHudTransition) intelHud.style.setProperty('transition', priorHudTransition, priorHudTransitionPriority);
else intelHud.style.removeProperty('transition');
manager._updateHudButtonState();
manager.cockpitView.active = prior.cockpitActive;
manager.cockpitView.update = realCockpitUpdate;
@@ -2284,13 +2382,13 @@ async function main() {
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
else hud.style.removeProperty('--cockpit-utility-top');
await waitForLayout();
if (manager._leftStackLayoutFrame !== null) {
cancelAnimationFrame(manager._leftStackLayoutFrame);
manager._leftStackLayoutFrame = null;
if (manager._panelLayout._leftStackLayoutFrame !== null) {
cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
manager._panelLayout._leftStackLayoutFrame = null;
}
if (manager._rightStackLayoutFrame !== null) {
cancelAnimationFrame(manager._rightStackLayoutFrame);
manager._rightStackLayoutFrame = null;
if (manager._panelLayout._rightStackLayoutFrame !== null) {
cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
manager._panelLayout._rightStackLayoutFrame = null;
}
for (const [id, state] of Object.entries(prior.panels)) {
const panel = document.getElementById(id);
@@ -2301,7 +2399,7 @@ async function main() {
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
}
manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
localStorage.clear();
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -2316,6 +2414,8 @@ async function main() {
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
&& hud.hidden === prior.hudHidden
&& signal.hidden === prior.signalHidden
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
&& intelHud.style.getPropertyPriority('transition') === priorHudTransitionPriority
&& manager.hud.getMode() === prior.hudMode
&& manager.hud.getVariant() === prior.hudVariant
&& Object.entries(prior.panels).every(([id, state]) => {
@@ -2324,7 +2424,7 @@ async function main() {
&& panel.getAttribute('style') === state.style
&& panel.getAttribute('aria-hidden') === state.ariaHidden;
})
&& manager._leftStackPreferredPanelId === prior.preferredLeftPanelId
&& manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId
&& JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage)
&& (!prior.focusId || document.activeElement?.id === prior.focusId);
}
@@ -2652,9 +2752,15 @@ async function main() {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
await radio.play();
const beforeSpace = radio.getUIState().audioState;
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' }));
// Simulate an already-started hold-Space session; click-started open mic
// deliberately ignores Space takeover, and short taps never claim voice.
const priorPushToTalkMode = voice.pushToTalkMode;
voice.pushToTalkMode = true;
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
await new Promise((resolve) => setTimeout(resolve, 700));
const afterSpace = radio.getUIState().audioState;
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' }));
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
voice.pushToTalkMode = priorPushToTalkMode;
await radio.play();
voice.setVoiceSpeaker('ai');
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
@@ -2719,7 +2825,7 @@ async function main() {
const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect();
const sliderRect = slider.getBoundingClientRect();
const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect();
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const usable = sliderRect.width - 14;
const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1);
const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, {
@@ -2745,7 +2851,7 @@ async function main() {
};
};
const playsBefore = window.__qaRadioPlayCalls.length;
const frozenSignature = style._radioTunerBandSignature;
const frozenSignature = style._radioControls._radioTunerBandSignature;
pointer('pointerdown', xFor(0));
const left = snapshot();
const centerCoordinate = (count - 1) / 2;
@@ -2765,14 +2871,14 @@ async function main() {
visible: !document.getElementById('radio-tuner').hidden,
stationCount: count,
sliderMax: Number(slider.max),
bandFrozen: style._radioTunerBandSignature === frozenSignature,
bandFrozen: style._radioControls._radioTunerBandSignature === frozenSignature,
left,
center,
shifted,
right,
leftExpectedId: style._radioTunerStations[0]?.id || null,
centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
rightExpectedId: style._radioTunerStations[count - 1]?.id || null,
leftExpectedId: style._radioControls._radioTunerStations[0]?.id || null,
centerExpectedId: style._radioControls._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
rightExpectedId: style._radioControls._radioTunerStations[count - 1]?.id || null,
centerExpected: Math.floor(centerCoordinate + 0.5),
tapeDelta,
needleDelta,
@@ -2817,7 +2923,7 @@ async function main() {
const slider = document.getElementById('radio-tuner-slider');
// Capture a fresh gesture's complete presentation anchor before moving
// to a different absolute directory station.
const beforeStations = [...style._radioTunerStations];
const beforeStations = [...style._radioControls._radioTunerStations];
const sliderRect = slider.getBoundingClientRect();
const xFor = (index) => sliderRect.left + 7
+ (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1);
@@ -2834,7 +2940,7 @@ async function main() {
const flyToCalls = [];
camera.flyTo = (options) => flyToCalls.push(options);
const before = {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: beforeStations.map((station) => station.id),
slot: Number(slider.value),
selectedId: radio.getUIState().selected?.id || null,
@@ -2848,10 +2954,10 @@ async function main() {
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const flyToCallsBeforeCancel = flyToCalls.length;
pointer('pointercancel', targetIndex);
const afterStations = style._radioTunerStations;
const afterStations = style._radioControls._radioTunerStations;
const afterState = radio.getUIState();
const after = {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: afterStations.map((station) => station.id),
slot: Number(slider.value),
selectedId: afterState.selected?.id || null,
@@ -2972,7 +3078,7 @@ async function main() {
const tunerRefreshTarget = await page.evaluate(() => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const stations = gev.styleManager._radioTunerStations;
const stations = gev.styleManager._radioControls._radioTunerStations;
const targetIndex = 1;
const rect = slider.getBoundingClientRect();
const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1);
@@ -3055,7 +3161,7 @@ async function main() {
const max = Number(slider.max);
const direction = startSlot < max ? 1 : -1;
const targetIndex = startSlot + direction;
const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null;
const targetId = gev.styleManager._radioControls._radioTunerStations[targetIndex]?.id || null;
const rect = slider.getBoundingClientRect();
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max);
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
@@ -3101,23 +3207,32 @@ async function main() {
&& tunerDirectRelease.spread < 0.5,
JSON.stringify(tunerDirectRelease),
);
// Earlier cases scroll and resize the directory. Bring the physical drag
// target back into its scroller before deriving viewport mouse coordinates.
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
await page.waitForFunction(() => {
const slider = document.getElementById('radio-tuner-slider');
const rect = slider.getBoundingClientRect();
const x = rect.left + 7 + (rect.width - 14) * Number(slider.value) / Math.max(1, Number(slider.max));
return document.elementFromPoint(x, rect.top + rect.height / 2) === slider;
});
const tunerCommitTarget = await page.evaluate(() => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const targetIndex = 11;
slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 }));
const station = gev.styleManager._radioTunerStations[targetIndex];
const station = gev.styleManager._radioControls._radioTunerStations[targetIndex];
const rect = slider.getBoundingClientRect();
const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max));
window.__qaRadioDelayNextPlay = true;
return {
id: station.id,
targetIndex,
count: gev.styleManager._radioTunerStations.length,
frozenSignature: gev.styleManager._radioTunerBandSignature,
count: gev.styleManager._radioControls._radioTunerStations.length,
frozenSignature: gev.styleManager._radioControls._radioTunerBandSignature,
startX: rect.left + 7 + (rect.width - 14) * currentRatio,
targetX: rect.left + 7 + (rect.width - 14) * targetIndex
/ Math.max(1, gev.styleManager._radioTunerStations.length - 1),
/ Math.max(1, gev.styleManager._radioControls._radioTunerStations.length - 1),
y: rect.top + rect.height / 2,
};
});
@@ -3135,11 +3250,11 @@ async function main() {
audioState: state.audioState,
tuningStatic: state.tuningStatic,
awaiting: state.tuningAwaitingStationId,
signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature,
selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
signatureStable: gev.styleManager._radioControls._radioTunerBandSignature === target.frozenSignature,
selectedIndex: gev.styleManager._radioControls._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
sliderSlot: Number(slider.value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
pinned: gev.styleManager._radioTunerBandPinnedForNavigation,
pinned: gev.styleManager._radioControls._radioTunerBandPinnedForNavigation,
};
}, tunerCommitTarget);
check(
@@ -3183,7 +3298,7 @@ async function main() {
y: rect.top + rect.height / 2,
ratio,
usableWidth: rect.width - 14,
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
};
});
const microDragSamples = [];
@@ -3199,7 +3314,7 @@ async function main() {
pixelDelta,
slot: Number(slider.value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
};
}, delta));
}
@@ -3238,9 +3353,9 @@ async function main() {
const gev = window.__godsEyeView;
const style = gev.styleManager;
const before = {
signature: style._radioTunerBandSignature,
ids: style._radioTunerStations.map((station) => station.id),
selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId),
signature: style._radioControls._radioTunerBandSignature,
ids: style._radioControls._radioTunerStations.map((station) => station.id),
selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId),
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
};
const current = gev.viewer.camera.positionCartographic;
@@ -3256,11 +3371,11 @@ async function main() {
});
gev.viewer.camera.changed.raiseEvent();
await new Promise((resolve) => setTimeout(resolve, 400));
const stations = gev.styleManager._radioTunerStations;
const stations = gev.styleManager._radioControls._radioTunerStations;
const selectedIndex = stations.findIndex((station) => station.id === selectedId);
return {
before,
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: stations.map((station) => station.id),
count: stations.length,
selectedIndex,
@@ -3279,14 +3394,14 @@ async function main() {
const nextNeedleBefore = await page.evaluate(() => {
const style = window.__godsEyeView.styleManager;
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId);
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length;
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedPoolIndex = style._radioControls._radioTunerPool.findIndex((station) => station.id === selectedId);
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioControls._radioTunerPool.length;
return {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
selectedIndex,
count: style._radioTunerStations.length,
expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null,
count: style._radioControls._radioTunerStations.length,
expectedId: style._radioControls._radioTunerPool[expectedPoolIndex]?.id || null,
};
});
await page.$eval('#radio-next-btn', (button) => button.click());
@@ -3294,15 +3409,15 @@ async function main() {
const nextNeedleAfter = await page.evaluate(() => {
const style = window.__godsEyeView.styleManager;
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
return {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
selectedId,
selectedIndex,
count: style._radioTunerStations.length,
count: style._radioControls._radioTunerStations.length,
sliderSlot: Number(document.getElementById('radio-tuner-slider').value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
pinned: style._radioTunerBandPinnedForNavigation,
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
};
});
check(
@@ -3322,13 +3437,14 @@ async function main() {
const slider = document.getElementById('radio-tuner-slider');
const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
key: value,
}));
const markerId = () => gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const startIndex = Number(slider.value);
const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1);
const expectedId = style._radioTunerStations[expectedIndex]?.id || null;
const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1);
const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null;
const playsBefore = window.__qaRadioPlayCalls.length;
key('keydown', 'ArrowRight');
const preview = {
@@ -3353,7 +3469,7 @@ async function main() {
tuningActive: radio.getUIState().tuningActive,
playDelta: window.__qaRadioPlayCalls.length - committedPlays,
};
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const pageStep = Math.max(1, Math.round((count - 1) / 10));
const previewAndCancel = (value, expectedSlot) => {
const beforePlays = window.__qaRadioPlayCalls.length;
@@ -3376,7 +3492,8 @@ async function main() {
previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)),
previewAndCancel('PageDown', Math.max(0, base - pageStep)),
];
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation };
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation,
panelStayedOpen: !document.getElementById('radio-panel').classList.contains('collapsed') };
});
check(
'keyboard preview is silent, key release commits once, and Escape restores the committed station',
@@ -3388,7 +3505,8 @@ async function main() {
&& !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1
&& tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId
&& tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0,
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0
&& tunerKeyboard.panelStayedOpen,
JSON.stringify(tunerKeyboard),
);
check(
@@ -3405,7 +3523,7 @@ async function main() {
const radio = gev.dataManager.layers.get('radio').module;
const slider = document.getElementById('radio-tuner-slider');
const rect = slider.getBoundingClientRect();
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const centerIndex = Math.floor((count - 1) / 2 + 0.5);
const clientX = rect.left + rect.width / 2;
slider.dispatchEvent(new PointerEvent('pointerdown', {
@@ -3425,7 +3543,7 @@ async function main() {
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
markerId: gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null,
expectedId: style._radioTunerStations[centerIndex]?.id || null,
expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null,
};
slider.dispatchEvent(new PointerEvent('pointercancel', {
bubbles: true,
@@ -3450,7 +3568,7 @@ async function main() {
const fullPoolNavigation = await page.evaluate(() => {
const gev = window.__godsEyeView;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioTunerPool;
const pool = gev.styleManager._radioControls._radioTunerPool;
const ids = pool.map((station) => station.id);
const selectWithoutPlayback = (index) => radio.selectStation(ids[index], {
autoplay: false,
@@ -3500,7 +3618,7 @@ async function main() {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const rect = slider.getBoundingClientRect();
const count = gev.styleManager._radioTunerStations.length;
const count = gev.styleManager._radioControls._radioTunerStations.length;
const current = Number(slider.value);
const target = Math.min(count - 1, current + 4);
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1);
@@ -3695,7 +3813,7 @@ async function main() {
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
const pool = gev.styleManager._radioTunerPool;
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length];
@@ -4390,7 +4508,10 @@ async function main() {
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await sleep(450);
await page.waitForFunction((priorScroll) => (
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
const expandedContextRadioAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env node
/** Exercise installed Scene controls and real project/playback operations. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const shots = path.resolve('qa-shots/scene-controls');
fs.mkdirSync(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let promptValue = 'QA Scene';
page.on('dialog', async (dialog) => {
await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined);
});
let failures = 0;
function check(name, ok) {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector?._controls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() => {
window.__qaSceneState = [];
window.__godsEyeView.sceneDirector.subscribe((notification) =>
window.__qaSceneState.push(notification),
);
});
await page.click('[data-collapse-target="scene-panel"]');
await page.click('#scene-new-btn');
await page.waitForFunction(
() =>
document.querySelector('#scene-select option:checked')?.textContent ===
'QA Scene',
);
check(
'New creates and selects a persisted empty scene',
await page.evaluate(() => {
const director = window.__godsEyeView.sceneDirector;
const saved = JSON.parse(
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
return (
director._getSelectedScene().shots.length === 0 &&
!!document.querySelector('.scene-shot-empty') &&
saved.scenes.some((scene) => scene.title === 'QA Scene')
);
}),
);
await page.click('#scene-capture-btn');
await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 1,
);
check(
'Capture stores the actual camera and visual state',
await page.evaluate(() => {
const director = window.__godsEyeView.sceneDirector;
const shot = director._getSelectedScene().shots[0];
const current = director.styleManager.getCameraState();
return (
Number.isFinite(shot.camera.lat) &&
Math.abs(shot.camera.lat - current.lat) < 0.01 &&
shot.visual.style === director.styleManager.getVisualState().style
);
}),
);
promptValue = '<b>QA shot</b>';
await page.click('.scene-shot-label', { count: 2 });
await page.waitForFunction(
() =>
document.querySelector('.scene-shot-label')?.textContent ===
'<b>QA shot</b>',
);
check(
'Rename persists literal text without interpreting markup',
await page.evaluate(
() =>
!document.querySelector('.scene-shot-label b') &&
JSON.parse(
localStorage.getItem('godsEyeView.sceneProject.v2'),
).scenes.some((scene) =>
scene.shots.some((shot) => shot.title === '<b>QA shot</b>'),
),
),
);
await page.click('#scene-capture-btn');
await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 2,
);
await page.click('.scene-shot-label');
check(
'Shot selection and Update use the selected shot',
await page.evaluate(() => {
const director = window.__godsEyeView.sceneDirector;
const scene = director._getSelectedScene();
const count = scene.shots.length;
document.getElementById('scene-update-shot-btn').click();
return (
director._selectedShotId === scene.shots[0].id &&
scene.shots.length === count &&
document.querySelector('.scene-shot-row.active .scene-shot-label')
.textContent === scene.shots[0].title
);
}),
);
await page.evaluate(() => {
window.__sceneExports = [];
const create = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
if (blob.type === 'application/json')
window.__sceneExports.push(blob.text());
return create(blob);
};
});
await page.click('#scene-export-btn');
const exported = await page.evaluate(async () =>
JSON.parse(await window.__sceneExports[0]),
);
check(
'Export produces the current project JSON',
exported.scenes.some(
(scene) => scene.title === 'QA Scene' && scene.shots.length === 2,
),
);
// Keep real camera/style playback while isolating this control check from
// live provider availability. Layer transactions are covered by their suites.
const scene = exported.scenes.find((item) => item.title === 'QA Scene');
scene.shots = scene.shots
.slice(0, 1)
.map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} }));
const fixture = { ...exported, scenes: [scene] };
const projectFile = path.join(shots, 'project.json');
fs.writeFileSync(projectFile, JSON.stringify(fixture));
const input = await page.$('#scene-import-file');
await input.uploadFile(projectFile);
await page.waitForFunction(
() =>
document.getElementById('scene-status').textContent ===
'Imported project.json',
);
check(
'Import replaces the project and clears the file input',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._project.scenes.length === 1 &&
document.querySelectorAll('.scene-shot-row').length === 1 &&
document.getElementById('scene-import-file').value === '',
),
);
await page.click('.scene-shot-btn');
await page.waitForFunction(
() =>
document.getElementById('scene-status').textContent.startsWith('Loaded:'),
{ timeout: 30000 },
);
check(
'LOAD completes the real visual and camera operation',
await page.evaluate(
() =>
!window.__godsEyeView.sceneDirector.running &&
document
.getElementById('scene-status')
.textContent.includes('<b>QA shot</b>'),
),
);
await page.click('#scene-start-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.sceneDirector.running &&
document.body.classList.contains('scene-playback-mode'),
);
check(
'Playback enters recording presentation and disables editing',
await page.evaluate(
() =>
document.getElementById('scene-capture-btn').disabled &&
document.getElementById('scene-start-btn').disabled &&
!document.getElementById('scene-stop-btn').disabled &&
window.__godsEyeView.styleManager.getControlState().recording,
),
);
// A focused panel owns Escape to collapse one level. Release panel focus
// before testing the global playback shortcut; the canvas is not tabbable.
await page.evaluate(() => document.activeElement?.blur());
check(
'global playback shortcut has document focus',
await page.evaluate(() => document.activeElement === document.body),
);
await page.keyboard.press('Escape');
await page.waitForFunction(
() =>
!window.__godsEyeView.sceneDirector.running &&
!document.body.classList.contains('scene-playback-mode'),
);
check(
'Escape ends playback and restores idle/recording controls',
await page.evaluate(
() =>
!document.getElementById('scene-start-btn').disabled &&
document.getElementById('scene-stop-btn').disabled &&
!document.getElementById('scene-download-btn').disabled &&
!window.__godsEyeView.styleManager.getControlState().recording,
),
);
// Escape can also collapse the Scene accordion. Reopen it through its
// installed disclosure before testing the next visible user action.
if (
await page.$eval('#scene-panel', (panel) =>
panel.classList.contains('collapsed'),
)
)
await page.click('[data-collapse-target="scene-panel"]');
await page.waitForSelector('#scene-download-btn', { visible: true });
await page.click('#scene-download-btn');
const metadata = await page.evaluate(async () =>
JSON.parse(await window.__sceneExports.at(-1)),
);
check(
'Run download contains the completed cancelled run',
Boolean(
metadata.endedAt &&
metadata.wasCancelled &&
Array.isArray(metadata.events),
),
);
await page.click('#scene-start-btn');
await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running);
await page.$eval('#scene-stop-btn', (button) => button.click());
await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running);
check(
'The installed Stop control also completes playback cleanup',
await page.evaluate(
() =>
!document.body.classList.contains('scene-playback-mode') &&
!document.getElementById('scene-start-btn').disabled,
),
);
const badFile = path.join(shots, 'invalid.json');
fs.writeFileSync(badFile, '{invalid');
await input.uploadFile(badFile);
await page.waitForFunction(() =>
document
.getElementById('scene-status')
.textContent.includes('invalid JSON'),
);
check(
'Invalid import reports failure and preserves the current project',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._project.scenes.length === 1 &&
window.__godsEyeView.sceneDirector._getSelectedScene().title ===
'QA Scene',
),
);
await page.screenshot({ path: path.join(shots, 'desktop.png') });
await page.setViewport({ width: 390, height: 844 });
await page.screenshot({ path: path.join(shots, 'narrow.png') });
check(
'Scene controls stay inside the narrow viewport',
await page.$eval('#scene-select', (element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth;
}),
);
await page.setViewport({ width: 1440, height: 900 });
await page.click('.scene-shot-danger');
await page.waitForFunction(
() => !!document.querySelector('.scene-shot-empty'),
);
check(
'Delete shot leaves the empty-state presentation',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._getSelectedScene().shots.length ===
0,
),
);
await page.click('#scene-delete-btn');
await page.waitForFunction(
() =>
!window.__godsEyeView.sceneDirector._project.scenes.some(
(item) => item.title === 'QA Scene',
),
);
check(
'Deleting the final scene restores the built-in recipes',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._project.scenes.length > 0 &&
document.querySelectorAll('#scene-select option').length > 0,
),
);
check(
'Scene subscriptions include current state and the completed native editing actions',
await page.evaluate(() => {
const seen = window.__qaSceneState;
const types = new Set(seen.map(({ change }) => change?.type));
return (
seen[0].initial &&
Object.isFrozen(seen[0].state) &&
[
'scene-created',
'shot-captured',
'shot-renamed',
'shot-updated',
'project-exported',
'project-imported',
'shot-deleted',
'scene-deleted',
'run-event',
].every((type) => types.has(type)) &&
seen
.filter(({ change }) => change?.shot)
.every(({ change }) => Object.isFrozen(change.shot))
);
}),
);
const teardown = await page.evaluate(async () => {
const director = window.__godsEyeView.sceneDirector;
const controls = director._controls;
const previousStatus = document.getElementById('scene-status').textContent;
const project = director._project;
let finish;
const gate = new Promise((resolve) => {
finish = resolve;
});
const pending = controls.run('import', {
name: 'late.json',
text: async () => {
await gate;
return '{"scenes":[]}';
},
});
const stopping = director.destroy();
const notificationsAtStop = window.__qaSceneState.length;
const stoppedSynchronously =
controls.destroyed &&
controls.removers.length === 0 &&
controls.rowRemovers.length === 0;
document.getElementById('scene-new-btn').click();
controls.run('capture');
finish();
await pending;
await stopping;
await director.destroy();
return {
stoppedSynchronously,
stateStopped: window.__qaSceneState.length === notificationsAtStop,
sameProject: director._project === project,
noLateStatus:
document.getElementById('scene-status').textContent === previousStatus,
rowsGone: !document.querySelector('.scene-shot-row'),
};
});
check(
'Disposal revokes fixed and row actions before waiting for scene work',
teardown.stoppedSynchronously && teardown.rowsGone,
);
check(
'Late import completion cannot replace the disposed project or status',
teardown.sameProject && teardown.noLateStatus,
);
check(
'Scene subscriptions stop before late imports settle',
teardown.stateStopped,
);
check(
'Scene control interaction produces no uncaught browser errors',
errors.length === 0,
);
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+1 -1
View File
@@ -70,7 +70,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+1 -1
View File
@@ -55,7 +55,7 @@ const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500))
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+1 -1
View File
@@ -61,7 +61,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
// Deterministic city-navigation exercise; road/flow responses are fixtures.
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { trafficFixtureResponse } from './traffic-fixtures.mjs';
const args = process.argv.slice(2);
const url = args[args.indexOf('--url') + 1] || 'http://localhost:4173';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const browser = await puppeteer.launch({
headless: true,
protocolTimeout: 120000,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-background-timer-throttling',
],
});
try {
for (const cctv of [false, true]) {
const page = await browser.newPage();
const errors = [];
let londonRequests = 0;
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.text().includes('[Data:Traffic]'))
console.log(message.text());
});
await page.setViewport({ width: 1280, height: 800 });
await page.setRequestInterception(true);
page.on('request', (request) => {
let response = trafficFixtureResponse(request);
const query = new URLSearchParams(request.postData()).get('data') || '';
if (query.includes('highway') && query.includes('(51.')) {
londonRequests++;
if (londonRequests === 1)
response = { status: 504, body: 'Temporary road source timeout' };
}
return response ? request.respond(response) : request.continue();
});
await page.goto(`${url}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: 60000,
});
await sleep(5000);
await page.keyboard.press('Escape');
await page.evaluate(async (withCctv) => {
const { viewer, dataManager } = window.__godsEyeView;
const C = await import('/node_modules/.vite/deps/cesium.js');
for (const id of ['satellites', 'flights', 'military'])
await dataManager.setEnabled(id, false);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-97.744, 30.267, 3200),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('traffic', true);
if (withCctv) await dataManager.setEnabled('cctv', true);
}, cctv);
const ready = () => {
const stats = window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats();
return stats.count > 0 && !stats.loading;
};
try {
await page.waitForFunction(ready, { timeout: 45000 });
} catch (error) {
console.log(
'Traffic timeout state',
await page.evaluate(() => {
const { viewer, dataManager } = window.__godsEyeView;
return {
stats: dataManager.layers.get('traffic').module.getStats(),
camera: viewer.camera.positionCartographic,
};
}),
);
throw error;
}
await sleep(2500);
await page.evaluate(async () => {
const { viewer } = window.__godsEyeView;
const C = await import('/node_modules/.vite/deps/cesium.js');
viewer.camera.flyTo({
destination: C.Cartesian3.fromDegrees(-0.1276, 51.5072, 3200),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
duration: 4,
});
});
const deadline = Date.now() + 45000;
while (londonRequests < 2 && Date.now() < deadline) await sleep(500);
assert.ok(
londonRequests >= 2,
'London must recover its failed request without a toggle',
);
await page.waitForFunction(ready, { timeout: 45000 });
const stats = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats(),
);
assert.equal(stats.error, null);
assert.deepEqual(errors, []);
console.log(
'PASS: Austin → London, first destination request fails then recovers',
{ cctv, londonRequests, count: stats.count },
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', false),
);
assert.equal(
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats().loading,
),
false,
);
await page.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -77,7 +77,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+22 -7
View File
@@ -25,6 +25,8 @@
* Visual proof saved to qa-shots/ (gitignored).
*
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
* Add --fixtures for synthetic roads and the recorded flow tile without a key;
* that mode does not qualify live source access or quota accounting.
* Exits non-zero on any FAIL. Does not commit anything.
*/
@@ -44,6 +46,8 @@ const getOpt = (name, dflt) => {
};
const APP_URL = getOpt('--url', 'http://localhost:4410');
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null;
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -53,7 +57,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
@@ -112,7 +116,8 @@ async function main() {
process.exit(2);
}
const statusBefore = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exit(2);
@@ -133,6 +138,11 @@ async function main() {
let exitCode = 0;
try {
const page = await browser.newPage();
const onFixture = (request) => {
const response = fixtureResponse(request);
if (response) void request.respond(response); else void request.continue();
};
if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); }
await page.setViewport({ width: 1440, height: 900 });
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
@@ -152,7 +162,8 @@ async function main() {
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(1500);
await sleep(5000);
await page.keyboard.press('Escape');
// ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom
// coverage (sim dots exist for (iv)). Mumbai proved too Overpass-cold for
@@ -240,16 +251,17 @@ async function main() {
// ── (iii) budget honesty ─────────────────────────────────────────────────
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
{
const statusAfter = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
const ok = grew >= 0 && grew <= flowRequests.length;
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', ok,
`before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok,
FIXTURES ? 'Synthetic source mode; live quota was not exercised' : `before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
if (!ok) exitCode = 1;
}
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
if (FIXTURES) page.off('request', onFixture);
await page.setRequestInterception(true);
const keylessFlowReqs = [];
page.on('request', (req) => {
@@ -263,6 +275,8 @@ async function main() {
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
return;
}
const response = fixtureResponse(req);
if (response) { void req.respond(response); return; }
try { req.continue(); } catch { /* already handled */ }
});
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -270,7 +284,8 @@ async function main() {
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(1500);
await sleep(5000);
await page.keyboard.press('Escape');
let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 });
if (!simStats || simStats.count === 0) {
// Public Overpass can throttle bursts across harness runs — one retry.
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env node
/** Exercise the real UI disposal path, including unrelated resource owners. */
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
function check(name, ok) {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
}
try {
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._dataManager &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
const counts = {};
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: '`',
code: 'Backquote',
bubbles: true,
cancelable: true,
}),
);
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
const owner =
name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout;
const observer = owner[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
observer.disconnect = () => {
counts[name]++;
return disconnect();
};
return true;
};
const observed = [
'_commandDockTrayObserver',
'_draggableResizeObserver',
'_leftStackResizeObserver',
'_rightStackResizeObserver',
'_leftStackMutationObserver',
'_rightStackMutationObserver',
].filter(watchObserver);
const cctvUnsubscribe = ui._cctvControls._cctvUnsubscribe;
counts.cctv = 0;
if (cctvUnsubscribe)
ui._cctvControls._cctvUnsubscribe = () => {
counts.cctv++;
return cctvUnsubscribe();
};
const contextFields = [
'_contextManagerUnsubscribe',
'_dataManagerVisibilityRequestUnsubscribe',
'_dataManagerVisibilityGuardUnsubscribe',
'_dataManagerBeforeDestroyUnsubscribe',
];
const contextConnected = contextFields.every(
(field) => typeof ui._contextControls[field] === 'function',
);
for (const field of contextFields) {
const unsubscribe = ui._contextControls[field];
counts[field] = 0;
ui._contextControls[field] = () => {
counts[field]++;
return unsubscribe?.();
};
}
const cockpit = ui.cockpitView;
const portal = ui._cockpitDisplayPortal;
const cockpitListenerCount = cockpit._listenerRemovers.length;
counts.cockpit = 0;
cockpit._listenerRemovers = cockpit._listenerRemovers.map(
(remove) => () => {
counts.cockpit++;
return remove();
},
);
const portalHomes = portal.records.map((record) => ({
group: record.group,
home: record.anchor.parentNode,
anchor: record.anchor,
}));
portal.setActive(true);
let releaseRestoration;
const restorationGate = new Promise((resolve) => {
releaseRestoration = resolve;
});
const restoreForDisposal = ui._contextControls.restoreForDisposal.bind(
ui._contextControls,
);
ui._contextControls.restoreForDisposal = async () => {
await restorationGate;
return restoreForDisposal();
};
const resizeHandler = ui._windowResizeHandler;
const removeEventListener = window.removeEventListener;
counts.resize = 0;
window.removeEventListener = function (type, callback, options) {
if (type === 'resize' && callback === resizeHandler) counts.resize++;
return removeEventListener.call(this, type, callback, options);
};
const { setSplitFlapText } = await import('/src/splitFlap.js');
const feedbackLabel = document.getElementById('global-loading-label');
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
const permanentText =
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
let stateNotifications = 0;
ui.subscribeShareState(() => stateNotifications++, { emitCurrent: false });
ui.subscribeLocationSearch(() => stateNotifications++, {
emitCurrent: false,
});
try {
const disposal = ui.dispose();
const focusBefore = document.activeElement;
const stoppedBeforeRestoration =
ui._panelLayout.destroyed &&
ui._panelPosition.destroyed &&
ui._feedback.destroyed &&
ui._recording.destroyed &&
cockpit.destroyed &&
portal.stopped &&
cockpit._listenerRemovers.length === 0 &&
portal.frames.size === 0 &&
cockpit.enter() === false &&
cockpit.navigateContext(1) === false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
const noDeferredFocus = document.activeElement === focusBefore;
releaseRestoration();
await disposal;
ui._syncShareState();
ui.subscribeShareState(() => stateNotifications++);
ui.subscribeLocationSearch(() => stateNotifications++);
const once = JSON.stringify(counts);
await ui.dispose();
window.dispatchEvent(new Event('resize'));
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return {
cockpitStoppedBeforeRestoration:
stoppedBeforeRestoration && noDeferredFocus,
cockpitReleased:
cockpit.disposed &&
cockpit.destroyed &&
counts.cockpit === cockpitListenerCount &&
cockpitListenerCount > 0 &&
portal.destroyed &&
portal.listeners.signal.aborted &&
portal.frames.size === 0 &&
ui._cockpitDisplayPortal === null &&
portalHomes.length === 4 &&
portalHomes.every(
(record) =>
record.group.parentNode === record.home &&
!record.anchor.isConnected,
),
connected: Boolean(
cctvUnsubscribe &&
resizeHandler &&
observed.includes('_commandDockTrayObserver'),
),
contextReleased:
contextConnected &&
ui._contextControls.destroyed &&
contextFields.every(
(field) =>
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every(
(name) =>
counts[name] === 1 &&
(name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout)[name] === null,
),
subscriptionReleased:
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null,
controlsReleased:
ui._radioControls.destroyed &&
ui._locationControls.destroyed &&
ui._cctvControls.destroyed,
feedbackTextPreserved:
permanentText?.nodeType === Node.TEXT_NODE &&
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
permanentText &&
permanentText.data === 'LOAD COMPLETE' &&
!feedbackLabel.classList.contains('gev-flap-active') &&
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
0,
shellWorkReleased:
ui._lifetime.destroyed &&
ui._lifetime.frames.size === 0 &&
ui._lifetime.timers.size === 0 &&
ui._lifetime.removers.size === 0,
frameRateReleased:
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
stateStopped: stateNotifications === 0,
idempotent: once === JSON.stringify(counts),
};
} finally {
releaseRestoration();
window.removeEventListener = removeEventListener;
}
});
check(
'visible frame-rate monitor is removed on UI disposal',
result.frameRateReleased,
);
check(
'real UI has the expected live resources before disposal',
result.connected,
);
check(
'UI disposal releases all Context subscriptions and stops its controls',
result.contextReleased,
);
check(
'UI disposal disconnects each active panel observer once',
result.observersReleased,
);
check(
'UI disposal releases the CCTV state subscription',
result.subscriptionReleased,
);
check(
'UI disposal removes the registered window resize handler',
result.resizeReleased,
);
check(
'Radio, Location and CCTV controls are disposed with the UI',
result.controlsReleased,
);
check(
'Cockpit actions and queued portal work stop before layer restoration',
result.cockpitStoppedBeforeRestoration,
);
check(
'Cockpit cleanup releases listeners and returns all Display groups home',
result.cockpitReleased,
);
check(
'feedback teardown preserves the permanent accessible text node',
result.feedbackTextPreserved,
);
check(
'shell teardown cancels deferred work and releases listeners',
result.shellWorkReleased,
);
check(
'state subscriptions stop synchronously with UI disposal',
result.stateStopped,
);
check('repeated UI disposal is inert', result.idempotent);
check(
'disposal and subsequent resize produce no uncaught browser errors',
errors.length === 0,
);
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+12 -5
View File
@@ -13,14 +13,14 @@
*
* Usage:
* node scripts/qa-vessel-cards.mjs --tag new # current code
* node scripts/qa-vessel-cards.mjs --tag old # after swapping in the old layer file
* node scripts/qa-vessel-cards.mjs --tag old # from a checkout of the comparison revision
* node scripts/qa-vessel-cards.mjs --ports rotterdam # subset
* node scripts/qa-vessel-cards.mjs --url http://localhost:4173
* node scripts/qa-vessel-cards.mjs --data synthetic # deterministic test fixture
* node scripts/qa-vessel-cards.mjs --data synthetic --headful # real-GPU capture
*
* A/B flow: run --tag new, `git show <old-rev>:src/data/aisLiveVessels.js >
* src/data/aisLiveVessels.js`, run --tag old, then `git restore` the file.
* A/B flow: run each accepted revision from a separate checkout, using the
* same source configuration and camera fixtures with distinct --tag values.
* Retained screenshots and same-basename JSON manifests land at
* qa-shots/vessel-cards-<live-aisstream|synthetic-fixture>-<tag>-<port>.*.
* The name and manifest both record the data source; the manifest also records
@@ -184,7 +184,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
@@ -253,6 +253,10 @@ async function main() {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
await page.evaluateOnNewDocument(() => {
// This harness inspects cards, so keep first-run chrome out of the view.
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
});
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -319,7 +323,10 @@ async function main() {
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
}, port);
const resettled = settled ? true : await page
// A changed camera invalidates the previous settled result. Let the new
// view schedule its tiles before checking readiness again.
await new Promise((resolve) => setTimeout(resolve, 1500));
const resettled = await page
.waitForFunction(() => {
const gev = window.__godsEyeView;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
+1 -1
View File
@@ -61,7 +61,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
+8 -2
View File
@@ -32,14 +32,20 @@ fs.mkdirSync(shotsDir, { recursive: true });
const chromeCandidates = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate));
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
});
const page = await browser.newPage();
const failures = [];
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env node
/** Rendered acceptance for style transitions and effect-state ownership. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
function check(name, passed) {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._visualEffects &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60_000 },
);
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.scene.tweens?.removeAll?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (-97.7431 * Math.PI) / 180,
latitude: (30.2568 * Math.PI) / 180,
height: 1200,
}),
orientation: { heading: 0, pitch: -0.85, roll: 0 },
});
viewer.scene.requestRender();
});
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
const settled = await page
.waitForFunction(
() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
for (let i = 0; i < primitives.length; i++) {
const primitive = primitives.get(i);
if (
typeof primitive.tilesLoaded === 'boolean' &&
!primitive.tilesLoaded
)
return false;
}
return true;
},
{ timeout: 60_000 },
)
.then(
() => true,
() => false,
);
check('visible tile content settles before visual captures', settled);
fs.mkdirSync('qa-shots/visual-effects', { recursive: true });
for (const style of [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
'snow',
'normal',
]) {
await page.evaluate(
(name) => window.__godsEyeView.styleManager.setStyle(name),
style,
);
await page.waitForFunction(
() => window.__godsEyeView.styleManager.transitions.size === 0,
{ timeout: 10_000 },
);
const state = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const effects = manager._visualEffects;
const visible = Object.entries(effects.stages)
.filter(([, stage]) => stage.enabled)
.map(([name]) => name);
const animated = Object.values(effects.stages).some(
(stage) =>
stage.enabled &&
stage.uniforms.time !== undefined &&
stage.uniforms.intensity > 0.001,
);
return {
active: manager.activeStyle,
visible,
clock: effects.frameId !== null,
animated,
};
});
check(
`${style}: only the selected style remains visible`,
state.active === style &&
JSON.stringify(state.visible) ===
JSON.stringify(style === 'normal' ? [] : [style]),
);
check(
`${style}: the animation clock follows visible animated stages`,
state.clock === state.animated,
);
if (['normal', 'surveillance', 'thermal'].includes(style)) {
await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` });
if (style === 'surveillance')
await page.evaluate(() =>
window.__godsEyeView.viewer.camera.lookRight(0.2),
);
}
}
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setStyle('noir');
manager.setStyle('retro');
manager.setStyle('normal');
});
await page.waitForFunction(
() => window.__godsEyeView.styleManager.transitions.size === 0,
{ timeout: 10_000 },
);
check(
'rapid switches settle to Normal without an old style or clock',
await page.evaluate(() => {
const effects = window.__godsEyeView.styleManager._visualEffects;
return (
effects.frameId === null &&
Object.values(effects.stages).every((stage) => !stage.enabled)
);
}),
);
check(
'bloom actions, effect state and saved snapshot agree',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const states = [];
const unsubscribe = manager.subscribeShareState((value) =>
states.push(value),
);
const result = manager.setBloom({ enabled: true, intensityPct: 98 });
unsubscribe();
const published = states.at(-1);
const snapshot = manager.getVisualState();
return (
states[0].initial &&
states.length > 1 &&
Object.isFrozen(published.state.options) &&
published.state.bloomEnabled &&
published.state.options.bloomIntensity === 98 &&
result.ok &&
result.bloom.intensityPct === 98 &&
snapshot.bloom.intensity === 98 &&
manager._visualEffects.bloomIntensity === 98 &&
manager._bloomStage.enabled
);
}),
);
check(
'sharpen actions, effect state and saved snapshot agree',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const result = manager.setSharpen({ enabled: true, intensityPct: 61 });
return (
result.ok &&
result.sharpen.intensityPct === 61 &&
manager.getVisualState().sharpen.intensity === 61 &&
manager._visualEffects.sharpenIntensity === 0.61 &&
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
);
}),
);
await page.setViewport({ width: 620, height: 900 });
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' });
const teardown = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const effects = manager._visualEffects;
manager.setStyle('retro');
const owned = [...Object.values(effects.stages), effects.sharpenStage];
const collection = manager.viewer.scene.postProcessStages;
const previousBloom = effects.previousBloom;
const pending = manager.dispose();
const stoppedBeforeAwait =
effects.stopped &&
effects.frameId === null &&
effects.transitions.size === 0;
await pending;
return {
stoppedBeforeAwait,
removed: owned.every((stage) => !collection.contains(stage)),
restored:
effects.bloomStage.enabled === previousBloom.enabled &&
Object.entries(previousBloom.uniforms).every(
([name, value]) => effects.bloomStage.uniforms[name] === value,
),
};
});
check(
'UI teardown stops effect animation before its first await',
teardown.stoppedBeforeAwait,
);
check(
'UI teardown removes owned stages and restores borrowed bloom',
teardown.removed && teardown.restored,
);
check(
'effects do not introduce uncaught browser errors',
errors.length === 0,
);
if (errors.length) console.log(JSON.stringify(errors));
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/** Browser acceptance for application shortcuts and generated parameter controls. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, passed) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures += 1;
};
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setStyle('normal');
document.activeElement?.blur();
});
await page.keyboard.press('2');
check(
'native number shortcut selects CRT',
await page.evaluate(
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
),
);
const initial = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager._sliderPanel.classList.remove('collapsed');
const slider = manager._sliderContainer.querySelector('input');
window.__qaOldSlider = slider;
const name = 'pixelation';
window.__qaParameter = {
name,
before: manager.stages.retro.uniforms[name],
};
slider.focus();
return {
before: Number(slider.value),
step: Number(slider.step),
label: slider.getAttribute('aria-label'),
};
});
check(
'generated range has an accessible label and a numeric step',
initial.label === 'Pixelation' && initial.step > 0,
);
await page.keyboard.press('ArrowRight');
const changed = await page.evaluate(() => {
const slider = window.__qaOldSlider;
const manager = window.__godsEyeView.styleManager;
return {
value: Number(slider.value),
readout: slider.nextElementSibling.textContent,
uniform: manager.stages.retro.uniforms[window.__qaParameter.name],
};
});
check(
'native range input changes the shader and formatted readout',
changed.value > initial.before &&
changed.uniform === changed.value &&
Number(changed.readout) === changed.value,
);
await page.keyboard.press('3');
check(
'focused range retains native input instead of changing style',
await page.evaluate(
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
),
);
check(
'replacing a style revokes its detached slider',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const before = manager.stages.retro.uniforms[window.__qaParameter.name];
manager.setStyle('thermal');
window.__qaOldSlider.value = '0';
window.__qaOldSlider.dispatchEvent(new Event('input', { bubbles: true }));
return (
manager.stages.retro.uniforms[window.__qaParameter.name] === before
);
}),
);
check(
'normal style clears rows; reopening restores current shader values',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setStyle('normal');
const cleared = manager._sliderContainer.children.length === 0;
manager.setStyle('retro');
return (
cleared &&
Number(manager._sliderContainer.querySelector('input').value) ===
manager.stages.retro.uniforms[window.__qaParameter.name]
);
}),
);
const hudBefore = await page.evaluate(() => {
document.getElementById('hud-layout-select').focus();
return window.__godsEyeView.styleManager.hud.visible;
});
await page.keyboard.press('h');
check(
'HUD select keeps its native typing and selection',
await page.evaluate(
(visible) =>
document.activeElement?.id === 'hud-layout-select' &&
window.__godsEyeView.styleManager.hud.visible === visible,
hudBefore,
),
);
const displayBefore = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._setBloomEnabled(true);
manager._bloomSlider.value = '80';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
manager._bloomSlider.focus();
return {
value: Number(manager._bloomSlider.value),
contrast: manager._bloomStage.uniforms.contrast,
};
});
await page.keyboard.press('ArrowRight');
check(
'native Display slider updates effect state',
await page.evaluate((before) => {
const manager = window.__godsEyeView.styleManager;
return (
Number(manager._bloomSlider.value) > before.value &&
manager._bloomStage.uniforms.contrast < before.contrast
);
}, displayBefore),
);
check(
'Display buttons toggle current state once',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const before = manager.bloomEnabled;
manager._bloomBtn.click();
const changed = manager.bloomEnabled !== before;
manager._bloomBtn.click();
return changed && manager.bloomEnabled === before;
}),
);
check(
'Display rebind removes old listeners',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._initUI();
const before = manager.bloomEnabled;
manager._bloomBtn.click();
return manager.bloomEnabled !== before;
}),
);
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
await page.waitForFunction(
() =>
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
'mobile',
);
await page.evaluate(() => {
const slider =
window.__godsEyeView.styleManager._sliderContainer.querySelector('input');
slider.focus();
slider.scrollIntoView({ block: 'center', behavior: 'instant' });
});
await page.screenshot({ path: 'qa-shots/visual-input/narrow.png' });
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
window.__qaCurrentSlider = manager._sliderContainer.querySelector('input');
manager._applicationShortcuts.destroy();
manager._styleParameters.destroy();
document.activeElement?.blur();
});
await page.keyboard.press('3');
check(
'destroyed application shortcuts cannot change the selected style',
await page.evaluate(
() => window.__godsEyeView.styleManager.activeStyle === 'retro',
),
);
check(
'destroyed parameter controls cannot write through a retained element',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const before = manager.stages.retro.uniforms[window.__qaParameter.name];
window.__qaCurrentSlider.value = '0';
window.__qaCurrentSlider.dispatchEvent(
new Event('input', { bubbles: true }),
);
return (
manager._sliderContainer.children.length === 0 &&
manager.stages.retro.uniforms[window.__qaParameter.name] === before
);
}),
);
check(
'destroyed Display controls cannot change settings',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._displayControls.destroy();
const before = manager.bloomEnabled;
const contrast = manager._bloomStage.uniforms.contrast;
manager._bloomBtn.click();
manager._bloomSlider.value = '15';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
return (
manager.bloomEnabled === before &&
manager._bloomStage.uniforms.contrast === contrast
);
}),
);
check('runtime has no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+1 -1
View File
@@ -39,7 +39,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const CHROME_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
+1 -1
View File
@@ -30,7 +30,7 @@ if (fixtureSha256 !== expectedFixtureSha256) {
const appOrigin = new URL(appUrl).origin;
const browser = await puppeteer.launch({
headless: 'new',
executablePath: puppeteer.executablePath(),
executablePath: await puppeteer.executablePath(),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
+1
View File
@@ -10,6 +10,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const CREDENTIALS = Object.freeze([
{ name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] },
{ name: 'GOOGLE_MAPS_SERVER_API_KEY', label: 'Google Places / Street View server', keychain: [] },
{ name: 'CESIUM_ION_TOKEN', label: 'Cesium ion', keychain: [['cesium-ion', 'token']] },
{ name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] },
{ name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] },
+27 -6
View File
@@ -118,7 +118,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
@@ -3203,10 +3203,25 @@ async function main() {
await fl.update(v);
await window.__dfSettle(600);
}
const bb1 = window.__dfFindBB('aaa097');
const d1 = bb1 ? window.__dfCarto(bb1.position) : null;
// The floor owner intentionally retains the previous cell near an
// edge. Sample inside a cell, beyond that hysteresis band, so the raw
// coordinate's floor is unambiguously the floor the sprite must use.
// Wait for geometry, not a passing height; a missing clamp still fails.
const interiorLimit = 0.0005 - gf.CELL_HYSTERESIS_DEG - 0.00002;
const sampleDeadline = Date.now() + 8000;
let d1 = null;
let sampleInterior = false;
do {
await window.__dfSettle(250);
const bb1 = window.__dfFindBB('aaa097');
d1 = bb1 ? window.__dfCarto(bb1.position) : null;
sampleInterior = !!d1
&& Math.abs(d1.lat - cell(d1.lat)) < interiorLimit
&& Math.abs(d1.lon - cell(d1.lon)) < interiorLimit;
} while (!sampleInterior && Date.now() < sampleDeadline);
return {
startCold,
sampleInterior,
displayCell,
fixCell,
sameCellAsFix: displayCell.lat === fixCell.lat && displayCell.lon === fixCell.lon,
@@ -3249,6 +3264,10 @@ async function main() {
dfCorridor.controlFloor == null,
`control cell floor = ${dfCorridor.controlFloor}`);
record('display-floor/corridor: the height sample clears cell-boundary hysteresis',
dfCorridor.sampleInterior === true,
'the sampled coordinate lies inside one unambiguous floor cell');
record('display-floor/corridor: the sprite rides the corridor-warmed floor',
Number.isFinite(dfCorridor.spriteH) && Number.isFinite(dfCorridor.spriteFloor)
&& dfCorridor.spriteH >= dfCorridor.spriteFloor + DISPLAY_FLOOR_LIFT_M - 0.5,
@@ -3268,9 +3287,11 @@ async function main() {
const bbBefore = window.__dfFindBB('aaa097');
if (!bbBefore) return { error: 'aaa097 billboard missing' };
const d0 = window.__dfCarto(bbBefore.position);
// Plant a floor well ABOVE where it currently renders, across the block
// it can move within, so an UNFLOORED tracked entity is unmistakable.
const seeded = d0.h + 40;
// Plant above both the current billboard and this group's 400 m
// identity-probe floor. A poll can refresh the raw render altitude
// after d0 was read; a lower seed makes the negative model-ownership
// assertion impossible even when the clamp correctly stands aside.
const seeded = Math.max(d0.h, 400) + 40;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
gf.reportMeshFloorCell(cell(d0.lat) + dy * 0.001, cell(d0.lon) + dx * 0.001, seeded);
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs';
import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js';
import { tilesForBounds } from '../src/data/tomtomTiles.js';
const tile = readFileSync(
new URL(
'../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf',
import.meta.url,
),
);
const json = (body) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */
export function trafficFixtureResponse(request) {
const url = new URL(request.url());
if (url.pathname === '/api/tomtom/status')
return json({ hasKey: true, dailyCount: 0 });
if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname))
return { status: 200, contentType: 'application/x-protobuf', body: tile };
if (url.pathname !== '/api/overpass') return null;
const query = new URLSearchParams(request.postData()).get('data') || '';
if (!query.includes('highway')) return null;
const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/);
if (!match) throw new Error('Missing road fixture viewport');
const [south, west, north, east] = match.slice(1).map(Number);
const inside = ([lon, lat]) =>
lat >= south && lat <= north && lon >= west && lon <= east;
const segments = tilesForBounds({ south, west, north, east }, 12).flatMap(
({ z, x, y }) => decodeFlowTile(tile, z, x, y),
);
const elements = segments
.filter((segment) => segment.coords.some(inside))
.slice(0, 240)
.map((segment, index) => ({
type: 'way',
id: index + 1,
tags: { highway: 'primary', oneway: 'yes' },
geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })),
}));
const latitude = (south + north) / 2,
longitude = (west + east) / 2;
elements.push({
type: 'way',
id: 99999,
tags: { highway: 'residential' },
geometry: [
{ lat: latitude, lon: longitude },
{ lat: latitude + 0.003, lon: longitude + 0.003 },
],
});
return json({ elements });
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
*
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream
* fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its
* cached body as STALE instead of relaying the failure, and it backs off from
* upstream for the Retry-After period (bounded) so a rate limit is never
* hammered mid-cooldown. A failure with nothing cached is relayed as-is.
*
* Why the non-OK case matters: the browser layer treats a relayed 429 as a
* 45 s cooldown during which EVERY refresh (the 15 s poll and any tracking
* resolve) reports "adsb.lol rate limited", which the global status chip
* renders as LOAD FAILED while the map is still fully populated.
*
* @returns {import('vite').Plugin}
*/
export function adsbLolProxy() {
/** @type {string|null} Cached upstream JSON body. */
let _cache = null;
/** @type {number} Epoch-ms when the cache was populated. */
let _cacheAt = 0;
/** @type {number} Epoch-ms until which upstream is not contacted. */
let _cooldownUntil = 0;
/** @type {number} Upstream status that started the current cooldown. */
let _cooldownStatus = 0;
/** Response cache TTL (ms). */
const CACHE_MS = 12000;
/** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */
const RATE_LIMIT_COOLDOWN_MS = 30000;
/** Cooldown after a 5xx (ms). */
const SERVER_ERROR_COOLDOWN_MS = 15000;
/** Bounds for an upstream-supplied Retry-After (ms). */
const COOLDOWN_MIN_MS = 5000;
const COOLDOWN_MAX_MS = 120000;
const clampCooldown = (ms) =>
Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms));
/** Cooldown (ms) an upstream failure earns, honouring Retry-After when sane. */
function cooldownFor(upstream, now) {
const raw = upstream.headers?.get?.('retry-after');
if (raw) {
const seconds = Number(raw);
if (Number.isFinite(seconds) && seconds > 0)
return clampCooldown(seconds * 1000);
const at = Date.parse(raw);
if (Number.isFinite(at) && at > now) return clampCooldown(at - now);
}
return upstream.status === 429
? RATE_LIMIT_COOLDOWN_MS
: SERVER_ERROR_COOLDOWN_MS;
}
function serve(res, status, body, cacheStatus, extra = {}) {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-ADS-B-Cache': cacheStatus,
...(['HIT', 'STALE'].includes(cacheStatus)
? {
'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
}
: {}),
...extra,
});
res.end(body);
}
const installMiddleware = (server) => {
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
try {
const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) {
serve(res, 200, _cache, 'HIT');
return;
}
if (now < _cooldownUntil) {
const retryAfter = String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
);
if (_cache) {
serve(res, 200, _cache, 'STALE', {
'X-ADS-B-Upstream-Status': String(_cooldownStatus),
'X-ADS-B-Retry-After-Seconds': retryAfter,
});
return;
}
serve(
res,
_cooldownStatus || 503,
JSON.stringify({ error: 'adsb.lol upstream cooling down' }),
'NONE',
{ 'Retry-After': retryAfter },
);
return;
}
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
});
if (upstream.ok) {
const body = await upstream.text();
_cache = body;
_cacheAt = Date.now();
_cooldownUntil = 0;
_cooldownStatus = 0;
serve(res, 200, body, 'MISS');
return;
}
if (upstream.status === 429 || upstream.status >= 500) {
const failedAt = Date.now();
_cooldownUntil = failedAt + cooldownFor(upstream, failedAt);
_cooldownStatus = upstream.status;
console.warn(
`[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`,
);
if (_cache) {
// Do not wait for a failing response body before serving usable data.
upstream.body?.cancel().catch(() => {});
serve(res, 200, _cache, 'STALE', {
'X-ADS-B-Upstream-Status': String(upstream.status),
'X-ADS-B-Retry-After-Seconds': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
});
return;
}
}
const body = await upstream.text();
serve(
res,
upstream.status,
body,
'MISS',
_cooldownUntil > Date.now()
? {
'Retry-After': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
}
: {},
);
} catch (e) {
console.error('[adsb.lol Proxy]', e.message);
if (_cache) {
serve(res, 200, _cache, 'STALE');
return;
}
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ADS-B proxy error' }));
}
});
};
return {
name: 'adsblol-proxy',
configureServer: installMiddleware,
configurePreviewServer: installMiddleware,
};
}
+147
View File
@@ -0,0 +1,147 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
/**
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
* airports) and hex → aircraft type/registration. Free community API — cached
* aggressively: ONE upstream request per new key ever (404s negative-cached),
* persisted to disk so restarts don't re-hammer it. Adapted from skylight
* (MIT) server/src/enrich/routes.ts.
*/
export function adsbdbProxy() {
const TTL_MS = 24 * 3600_000;
const CACHE_PATH = path.join(process.cwd(), '.gev-cache', 'adsbdb.json');
let cache = { routes: {}, aircraft: {} };
let dirty = false;
let loaded = false;
const inflight = new Map();
async function loadOnce() {
if (loaded) return;
loaded = true;
try {
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
cache = { routes: parsed.routes ?? {}, aircraft: parsed.aircraft ?? {} };
} catch {
/* first run */
}
setInterval(async () => {
if (!dirty) return;
dirty = false;
try {
await fsp.mkdir(path.dirname(CACHE_PATH), { recursive: true });
await fsp.writeFile(CACHE_PATH, JSON.stringify(cache), 'utf8');
} catch {
dirty = true;
} // retry next tick
}, 15_000).unref?.();
}
const fresh = (e) => e && Date.now() - e.at < TTL_MS;
function parseRoute(json) {
const fr = json?.response?.flightroute;
if (!fr?.origin || !fr?.destination) return null;
const airport = (a) => ({
code: a.iata_code || a.icao_code || '',
name: a.municipality || a.name || '',
lat: Number.isFinite(a.latitude) ? a.latitude : null,
lon: Number.isFinite(a.longitude) ? a.longitude : null,
});
return {
airline: fr.airline?.name || null,
origin: airport(fr.origin),
destination: airport(fr.destination),
};
}
function parseAircraft(json) {
const a = json?.response?.aircraft;
if (!a) return null;
return {
typeCode: a.icao_type || null, // ICAO designator, e.g. "B738" — feeds classifyAircraft
typeName:
a.manufacturer && a.type
? `${a.manufacturer} ${a.type}`
: a.type || null,
registration: a.registration || null,
};
}
function lookup(kind, key) {
const store = kind === 'route' ? cache.routes : cache.aircraft;
if (fresh(store[key])) return Promise.resolve(store[key].data);
const ik = `${kind}:${key}`;
if (!inflight.has(ik)) {
inflight.set(
ik,
(async () => {
try {
const url =
kind === 'route'
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (res.ok) {
const data =
kind === 'route'
? parseRoute(await res.json())
: parseAircraft(await res.json());
store[key] = { at: Date.now(), data }; // data may be null — negative cache
dirty = true;
return data;
}
if (res.status === 404) {
store[key] = { at: Date.now(), data: null }; // known-missing — cache the miss
dirty = true;
}
// other statuses: leave uncached so we retry later
return fresh(store[key]) ? store[key].data : null;
} catch {
return fresh(store[key]) ? store[key].data : null; // network error → stale if any
} finally {
inflight.delete(ik);
}
})(),
);
}
return inflight.get(ik);
}
const installMiddleware = (server) => {
server.middlewares.use('/api/adsbdb', async (req, res) => {
await loadOnce();
const send = (status, obj) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
};
try {
const [, kind, rawKey] = String(req.url || '')
.split('?')[0]
.split('/');
if (kind === 'route') {
const cs = String(rawKey || '').toUpperCase();
if (!/^[A-Z0-9]{2,8}$/.test(cs))
return send(400, { error: 'invalid callsign' });
const data = await lookup('route', cs);
return send(200, data ? { found: true, ...data } : { found: false });
}
if (kind === 'type') {
const hex = String(rawKey || '').toLowerCase();
if (!/^[0-9a-f]{6}$/.test(hex))
return send(400, { error: 'invalid hex' });
const data = await lookup('aircraft', hex);
return send(200, data ? { found: true, ...data } : { found: false });
}
return send(404, { error: 'unknown endpoint' });
} catch (err) {
console.error('[adsbdb-proxy] request failed');
return send(500, { error: 'adsbdb proxy error' });
}
});
};
return {
name: 'adsbdb-proxy',
configureServer: installMiddleware,
configurePreviewServer: installMiddleware,
};
}
+701
View File
@@ -0,0 +1,701 @@
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
import {
coalesceProxyRequest,
readResponseJsonCapped,
} from '../common/http.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
// ---------------------------------------------------------------------------
// OpenSky OAuth2 token + response cache state
// ---------------------------------------------------------------------------
/** @type {string|null} Current OAuth2 bearer token. */
let _openskyToken = null;
/** @type {number} Epoch-ms when the current token expires. */
let _openskyTokenExpiry = 0;
/** @type {Promise<string|null>|null} In-flight token refresh promise (coalesces concurrent callers). */
let _openskyTokenPromise = null;
/** @type {string|null} Cached upstream response body (JSON text). */
let _openskyCacheBody = null;
/** @type {number} HTTP status of the cached response. */
let _openskyCacheStatus = 0;
/** @type {number} Epoch-ms when the response was cached. */
let _openskyCacheTime = 0;
/** @type {{requestedMode:string,usedMode:string,reason:string}|null} Auth metadata for the cached response. */
let _openskyCacheMeta = null;
/** @type {number|null} Source snapshot epoch from the cached OpenSky body. */
let _openskyCacheSourceEpochMs = null;
/** TTL for the OpenSky response cache (ms). */
const OPENSKY_CACHE_MS = 9000;
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
// The global /states/all this proxy fetches costs 4 CREDITS per call against
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
// the whole quota in ~8h and the layer then hard-died until the daily reset
// ("rate limited for 48h" owner report; auth itself was fine). Three levers:
// 1. Adaptive TTL: OpenSky returns X-Rate-Limit-Remaining on success; as the
// budget thins, the proxy stretches its cache TTL so a full day of
// continuous use never exhausts it.
// 2. 429 cooldown: honor X-Rate-Limit-Retry-After-Seconds — no upstream
// attempts until it passes (bounded 30 s … 30 min).
// 3. Serve-stale: while rate-limited/cooling, serve the last-good body (200 +
// X-OpenSky-Stale) so the layer keeps rendering instead of dying.
/** @type {number} Current adaptive TTL (ms) — starts at the base cache TTL. */
let _openskyTtlMs = OPENSKY_CACHE_MS;
/** @type {number} Epoch-ms before which no upstream fetch is attempted. */
let _openskyCooldownUntil = 0;
/**
* Picks the cache TTL from the remaining daily credit budget.
* Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the
* later tiers stretch the day: >2400 → ~3 h of full freshness, then 30 s
* (~2.5 h), 90 s (~5 h), 300 s (~8 h) ≈ 18+ h of continuous use per day.
* @param {number} remaining - X-Rate-Limit-Remaining header value.
* @returns {number} TTL in ms.
*/
function openskyAdaptiveTtlMs(remaining) {
if (!Number.isFinite(remaining)) return OPENSKY_CACHE_MS;
if (remaining > 2400) return OPENSKY_CACHE_MS;
if (remaining > 1200) return 30_000;
if (remaining > 400) return 90_000;
return 300_000;
}
/** @type {boolean} Guards duplicate auth-failure warnings in logs. */
let _openskyAuthWarned = false;
/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */
let _openskyAuthModeWarned = false;
/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
/** Set of valid OPENSKY_AUTH_MODE values. */
const OPENSKY_AUTH_MODE_SET = new Set(['basic', 'oauth', 'auto', 'anon']);
/** Regional civilian fallback cache, keyed by a coarse 0.25° view anchor. */
const _adsbLolPointCache = new Map();
/** Per-anchor single-flight map for concurrent regional fallback requests. */
const _adsbLolPointInFlight = new Map();
const ADSBLOL_POINT_CACHE_MS = 12000;
const ADSBLOL_POINT_CACHE_MAX = 80;
const ADSBLOL_POINT_RADIUS_NM = 250;
const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// A 200 response can still contain an old OpenSky snapshot. Past this point
// the viewport-scoped adsb.lol source is more honest and keeps local motion
// current instead of coasting a stale worldwide frame indefinitely.
const OPENSKY_SOURCE_STALE_MS = 120_000;
/**
* Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed.
*
* Uses the client_credentials grant against the OpenSky Keycloak realm.
* Concurrent callers share a single in-flight refresh promise so only
* one token request is issued at a time.
*
* @returns {Promise<string|null>} Bearer token string, or null if unavailable.
*/
export async function getOpenSkyToken() {
const now = Date.now();
// Return cached token if still valid (with 60 s safety margin)
if (_openskyToken && now < _openskyTokenExpiry - 60000) return _openskyToken;
// Coalesce concurrent refresh requests — if a refresh is already in-flight,
// return the same promise instead of issuing a duplicate token request
if (_openskyTokenPromise) return _openskyTokenPromise;
const clientId = process.env.OPENSKY_CLIENT_ID;
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
// Wrap the async token fetch in a shared promise stored in _openskyTokenPromise
_openskyTokenPromise = (async () => {
try {
const res = await fetch(
'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
},
);
let data = null;
try {
data = await res.json();
} catch {
data = null;
}
const accessToken = data?.access_token;
const expiresIn = Number(data?.expires_in);
if (!res.ok || !accessToken) {
if (!_openskyAuthWarned) {
const detail =
data?.error_description || data?.error || `HTTP ${res.status}`;
console.warn('[OpenSky] OAuth client_credentials failed:', detail);
_openskyAuthWarned = true;
}
_openskyToken = null;
_openskyTokenExpiry = 0;
return null;
}
_openskyToken = accessToken;
// Default to 1800 s (30 min) if expires_in is missing or non-finite
_openskyTokenExpiry =
Date.now() + (Number.isFinite(expiresIn) ? expiresIn : 1800) * 1000;
console.log(
'[OpenSky] OAuth token refreshed, expires in',
Number.isFinite(expiresIn) ? expiresIn : 1800,
's',
);
_openskyAuthWarned = false;
return _openskyToken;
} catch (err) {
if (!_openskyAuthWarned) {
console.warn(
'[OpenSky] OAuth token request failed:',
err?.message || String(err),
);
_openskyAuthWarned = true;
}
_openskyToken = null;
_openskyTokenExpiry = 0;
return null;
} finally {
// Clear the shared promise so the next caller can start a fresh refresh
_openskyTokenPromise = null;
}
})();
return _openskyTokenPromise;
}
/**
* Validate and normalize the OPENSKY_AUTH_MODE env value.
*
* @param {string} value - Raw env value (e.g. 'basic', 'oauth', 'auto', 'anon').
* @returns {string} One of the valid mode strings, or the default ('oauth').
*/
function normalizeOpenSkyAuthMode(value) {
const raw = String(value || '')
.trim()
.toLowerCase();
if (!raw) return OPENSKY_AUTH_MODE_DEFAULT;
if (OPENSKY_AUTH_MODE_SET.has(raw)) return raw;
if (!_openskyAuthModeWarned) {
console.warn(
`[OpenSky] Invalid OPENSKY_AUTH_MODE="${raw}", defaulting to "${OPENSKY_AUTH_MODE_DEFAULT}"`,
);
_openskyAuthModeWarned = true;
}
return OPENSKY_AUTH_MODE_DEFAULT;
}
/**
* Build standard response headers for OpenSky proxy responses.
*
* Includes diagnostic X-OpenSky-* headers so the client can inspect
* cache hit/miss status and which auth mode was actually used.
*
* @param {object} opts
* @param {string} opts.cacheStatus - 'HIT', 'MISS', or 'STALE'.
* @param {string} opts.requestedMode - The auth mode the config requested.
* @param {string} opts.usedMode - The auth mode actually used for the upstream call.
* @param {string} opts.reason - Human-readable reason string for diagnostics.
* @returns {Record<string,string>} Header object.
*/
function buildOpenSkyHeaders({
cacheStatus,
requestedMode,
usedMode,
reason,
staleSeconds,
retryAfterSeconds,
}) {
const headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-OpenSky-Cache': cacheStatus,
'X-OpenSky-Auth': usedMode,
'X-OpenSky-Auth-Mode-Requested': requestedMode,
'X-OpenSky-Auth-Mode-Used': usedMode,
'X-OpenSky-Auth-Reason': reason,
};
// Credit-governor extras (field-test fix 2026-07-06): the client can show a
// STALE cue / countdown without parsing the body.
if (Number.isFinite(staleSeconds))
headers['X-OpenSky-Stale-Seconds'] = String(Math.round(staleSeconds));
if (Number.isFinite(retryAfterSeconds))
headers['X-OpenSky-Retry-After-Seconds'] = String(
Math.round(retryAfterSeconds),
);
return headers;
}
export function adsbLolFallbackAnchor(req) {
const incoming = new URL(req?.url || '', 'http://localhost');
const latitude = requiredFiniteQueryNumber(incoming.searchParams, 'lat');
const longitude = requiredFiniteQueryNumber(incoming.searchParams, 'lon');
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
return null;
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
return null;
return { latitude, longitude };
}
async function fetchAdsbLolPointFallback(req) {
const anchor = adsbLolFallbackAnchor(req);
if (!anchor) return null;
const roundedLat = Math.round(anchor.latitude * 4) / 4;
const roundedLon = Math.round(anchor.longitude * 4) / 4;
const cacheKey = `${roundedLat.toFixed(2)},${roundedLon.toFixed(2)}`;
const cached = _adsbLolPointCache.get(cacheKey);
const now = Date.now();
if (cached && now - cached.cachedAt < ADSBLOL_POINT_CACHE_MS) {
return { ...cached, cacheStatus: 'HIT' };
}
const request = coalesceProxyRequest(
_adsbLolPointInFlight,
cacheKey,
async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const upstream = await fetch(
`https://api.adsb.lol/v2/lat/${roundedLat}/lon/${roundedLon}/dist/${ADSBLOL_POINT_RADIUS_NM}`,
{
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-adsblol-regional-fallback/1.0',
},
signal: controller.signal,
},
);
if (!upstream.ok) throw new Error(`upstream HTTP ${upstream.status}`);
const payload = await readResponseJsonCapped(
upstream,
ADSBLOL_POINT_MAX_RESPONSE_BYTES,
);
const normalized = normalizeAdsbLolPointResponse(payload);
const record = {
body: JSON.stringify(normalized),
cachedAt: Date.now(),
count: normalized.states.length,
};
_adsbLolPointCache.delete(cacheKey);
_adsbLolPointCache.set(cacheKey, record);
while (_adsbLolPointCache.size > ADSBLOL_POINT_CACHE_MAX) {
_adsbLolPointCache.delete(_adsbLolPointCache.keys().next().value);
}
return record;
} finally {
clearTimeout(timeoutId);
}
},
);
try {
const record = await request.promise;
return { ...record, cacheStatus: request.shared ? 'INFLIGHT' : 'MISS' };
} catch (error) {
if (!request.shared && error?.name !== 'AbortError') {
console.warn('[adsb.lol Flights Fallback]', error?.message || error);
}
return cached ? { ...cached, cacheStatus: 'STALE' } : null;
}
}
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
const fallback = await fetchAdsbLolPointFallback(req);
if (!fallback) return false;
res.writeHead(200, {
...buildOpenSkyHeaders({
cacheStatus: fallback.cacheStatus,
requestedMode,
usedMode: 'adsblol-regional',
reason,
}),
'X-Flight-Source': 'adsb.lol',
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
'X-Flight-Count': String(fallback.count),
});
res.end(fallback.body);
return true;
}
function openSkySourceEpochMs(body) {
try {
const seconds = Number(JSON.parse(body)?.time);
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null;
} catch {
return null;
}
}
function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
return (
Number.isFinite(sourceEpochMs) &&
now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS
);
}
/**
* Vite plugin: OpenSky Network proxy with multi-mode auth and response caching.
*
* Supports four auth modes controlled by OPENSKY_AUTH_MODE env:
* - 'oauth' (default) — client_credentials bearer token
* - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD
* - 'auto' — try OAuth first, fall back to Basic, then anon
* - 'anon' — no credentials
*
* Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On
* upstream failure the proxy serves a stale cached response if available, or
* a bounded 250 nm adsb.lol point snapshot around the current view anchor.
*
* @returns {import('vite').Plugin}
*/
export function openSkyProxy() {
const installMiddleware = (server) => {
server.middlewares.use('/api/opensky', async (req, res) => {
try {
const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
);
const now = Date.now();
const inCooldown = now < _openskyCooldownUntil;
// Fresh-enough cache (adaptive TTL) OR any cache during a 429
// cooldown: serve it without touching upstream. Stale-during-cooldown
// is deliberate (credit governor): last-good planes beat a dead layer.
if (
_openskyCacheBody &&
(now - _openskyCacheTime < _openskyTtlMs || inCooldown)
) {
if (
openSkySourceIsStale(_openskyCacheSourceEpochMs, now) &&
(await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_snapshot_stale_regional_fallback',
))
) {
return;
}
const cachedMeta = _openskyCacheMeta || {
requestedMode,
usedMode: 'unknown',
reason: 'cached',
};
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
cacheStatus: isStale ? 'STALE' : 'HIT',
requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown',
reason: isStale
? 'rate_limited_serving_stale'
: cachedMeta.reason || 'cached',
staleSeconds: isStale
? (now - _openskyCacheTime) / 1000
: undefined,
retryAfterSeconds: inCooldown
? (_openskyCooldownUntil - now) / 1000
: undefined,
}),
);
res.end(_openskyCacheBody);
return;
}
// Cooling down with nothing cached (cold start into a rate limit):
// synthesize the 429 locally — hammering upstream mid-cooldown can't
// succeed and just burns goodwill.
if (inCooldown) {
if (
await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_cooldown_regional_fallback',
)
)
return;
res.writeHead(
429,
buildOpenSkyHeaders({
cacheStatus: 'COOLDOWN',
requestedMode,
usedMode: 'none',
reason: 'rate_limited',
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
}),
);
res.end(
JSON.stringify({
error: 'OpenSky rate limited; proxy cooling down.',
}),
);
return;
}
const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { Accept: 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
if (requestedMode === 'basic') {
if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'basic_credentials';
} else {
reason = 'missing_basic_creds';
}
} else if (requestedMode === 'oauth') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else {
reason = 'oauth_invalid_or_missing';
}
} else if (requestedMode === 'auto') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'oauth_unavailable_fallback_basic';
} else {
reason = 'missing_oauth_and_basic_creds';
}
}
let upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers },
);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
requestedMode === 'auto' &&
usedMode === 'oauth' &&
hasBasicCreds
) {
const retryHeaders = {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
upstream = await fetch(
'https://opensky-network.org/api/states/all?extended=1',
{ headers: retryHeaders },
);
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
let body = await upstream.text();
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if (
upstream.ok &&
openSkySourceIsStale(sourceEpochMs, now) &&
(await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_snapshot_stale_regional_fallback',
))
) {
// Keep the last global snapshot available as a fail-soft cache,
// but do not label or render it as a fresh live result.
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = { requestedMode, usedMode, reason };
return;
}
if (upstream.status === 429) {
reason = 'rate_limited';
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
// 2 min when the header is absent) — no upstream attempts until then.
const retryAfterSec = Number(
upstream.headers.get('x-rate-limit-retry-after-seconds'),
);
const cooldownMs = Math.min(
Math.max(
Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
30_000,
),
30 * 60_000,
);
_openskyCooldownUntil = now + cooldownMs;
// Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) {
res.writeHead(
200,
buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode,
usedMode,
reason: 'rate_limited_serving_stale',
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
);
res.end(_openskyCacheBody);
return;
}
}
if (!upstream.ok && !_openskyCacheBody) {
const servedFallback = await serveAdsbLolPointFallback(
req,
res,
requestedMode,
`opensky_http_${upstream.status}_regional_fallback`,
);
if (servedFallback) return;
}
if (upstream.status === 401 || upstream.status === 403) {
if (requestedMode === 'basic' && !hasBasicCreds) {
body = JSON.stringify({
error:
'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.',
});
reason = 'missing_basic_creds';
} else if (requestedMode === 'oauth' && usedMode !== 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.',
});
reason = 'oauth_invalid_or_missing';
} else if (usedMode === 'basic') {
body = JSON.stringify({
error: 'OpenSky auth invalid. Username/password were rejected.',
});
reason = 'basic_invalid_credentials';
} else if (usedMode === 'oauth') {
body = JSON.stringify({
error:
'OpenSky auth invalid. OAuth client credentials were rejected.',
});
reason = 'oauth_invalid_credentials';
} else if (requestedMode === 'auto' && !hasBasicCreds) {
body = JSON.stringify({
error:
'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.',
});
reason = 'missing_oauth_and_basic_creds';
} else {
body = JSON.stringify({
error: 'OpenSky auth required.',
});
reason = 'auth_required';
}
}
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
res.writeHead(
upstream.status,
buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode,
reason,
}),
);
res.end(body);
} catch (e) {
console.error('[OpenSky Proxy]', e.message);
if (_openskyCacheBody) {
const cachedMeta = _openskyCacheMeta || {
requestedMode: normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
),
usedMode: 'unknown',
reason: 'cached_stale',
};
res.writeHead(
_openskyCacheStatus || 200,
buildOpenSkyHeaders({
cacheStatus: 'STALE',
requestedMode:
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
usedMode: cachedMeta.usedMode || 'unknown',
reason: cachedMeta.reason || 'cached_stale',
}),
);
res.end(_openskyCacheBody);
return;
}
const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
);
if (
await serveAdsbLolPointFallback(
req,
res,
requestedMode,
'opensky_proxy_error_regional_fallback',
)
)
return;
res.writeHead(
502,
buildOpenSkyHeaders({
cacheStatus: 'MISS',
requestedMode,
usedMode: 'error',
reason: 'proxy_error',
}),
);
res.end(JSON.stringify({ error: 'OpenSky proxy error' }));
}
});
};
return {
name: 'opensky-proxy',
configureServer: installMiddleware,
configurePreviewServer: installMiddleware,
};
}
+128
View File
@@ -0,0 +1,128 @@
import { getOpenSkyToken } from './opensky.js';
import { readCappedResponseText } from '../common/http.js';
/**
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
*
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
* own credit bucket, 4 credits per call on the free tier). OAuth via the
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
* client can fall back to its accumulated trail silently.
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
* of real history per aircraft. Treat as best-effort; data is ODbL —
* credit "adsb.lol (ODbL)" in the UI.
*/
export function trackBackfillProxies() {
const TRACK_CACHE_MS = 60000;
const TRACK_CACHE_MAX = 200;
const RESPONSE_CAP_BYTES = 5 * 1024 * 1024;
/** @type {Map<string, {at:number,status:number,body:string}>} */
const cache = new Map();
function cachePut(key, entry) {
cache.set(key, entry);
if (cache.size > TRACK_CACHE_MAX) {
const oldest = [...cache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
if (oldest) cache.delete(oldest[0]);
}
}
async function proxyJson(res, key, upstreamUrl, headers = {}) {
const cached = cache.get(key);
if (cached && Date.now() - cached.at < TRACK_CACHE_MS) {
res.statusCode = cached.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(cached.body);
return;
}
const upstream = await fetch(upstreamUrl, {
headers,
signal: AbortSignal.timeout(12000),
});
const { tooLarge, text } = await readCappedResponseText(
upstream,
RESPONSE_CAP_BYTES,
);
let body;
if (tooLarge) {
body = JSON.stringify({ error: 'Upstream track response too large' });
} else if (!upstream.ok) {
// Sanitize upstream error surface; status code is signal enough
body = JSON.stringify({ error: `Track source HTTP ${upstream.status}` });
} else {
body = text;
}
cachePut(key, { at: Date.now(), status: upstream.status, body });
res.statusCode = upstream.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(body);
}
function install(middlewares) {
middlewares.use('/api/opensky-track', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const icao24 = String(incoming.searchParams.get('icao24') || '')
.trim()
.toLowerCase();
if (!/^[0-9a-f]{6}$/.test(icao24)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({ error: 'icao24 must be a 6-char hex string' }),
);
return;
}
const token = await getOpenSkyToken();
await proxyJson(
res,
`osky:${icao24}`,
`https://opensky-network.org/api/tracks/all?icao24=${icao24}&time=0`,
token ? { Authorization: `Bearer ${token}` } : {},
);
} catch (error) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'OpenSky track fetch failed' }));
}
});
middlewares.use('/api/adsblol/trace', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const hex = String(incoming.searchParams.get('hex') || '')
.trim()
.toLowerCase();
if (!/^[0-9a-f~]{6,7}$/.test(hex)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({ error: 'hex must be a 6-7 char hex string' }),
);
return;
}
await proxyJson(
res,
`lol:${hex}`,
`https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`,
);
} catch (error) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'adsb.lol trace fetch failed' }));
}
});
}
return {
name: 'track-backfill-proxies',
configureServer(server) {
install(server.middlewares);
},
configurePreviewServer(server) {
install(server.middlewares);
},
};
}
+416
View File
@@ -0,0 +1,416 @@
import { createCctvCatalog } from './cctv/catalog.js';
import {
normalizeFeedType,
isVideoFeedType,
toFiniteNumber,
} from './cctv/normalize.js';
import {
buildSyntheticCctvSvg,
proxyMediaResponse,
fetchCctvImageFromUpstream,
fetchTxdotSnapshot,
fetchCctvMediaUpstream,
} from './cctv/media.js';
import {
CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_MAX_SOURCES_CEILING,
} from './cctv/constants.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
/**
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
*
* Endpoints:
* GET /api/cctv/sources — list all registered camera sources
* GET /api/cctv/health — per-camera health/status report
* GET /api/cctv/stream/:id — stream info (feedType, URLs) for a camera
* GET /api/cctv/media/:id — proxy live video/image media from upstream
* GET /api/cctv/frame/:id — single frame with fallback chain
*
* @returns {import('vite').Plugin}
*/
export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const getCctvSources = createCctvCatalog({ sourceRoot });
/** @type {Map<string,{id:string,status:string,sourceKind:string,label:string,message:string,updatedAt:number}>} */
const health = new Map();
/** Cap on health map entries to prevent unbounded growth. Sized to the
* CCTV_MAX_SOURCES ceiling so health/status observability is never evicted
* for any catalog the proxy can actually serve. */
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
const setHealth = (cameraId, patch) => {
// Evict oldest entries if the health map grows beyond the cap
if (!health.has(cameraId) && health.size >= HEALTH_MAX_ENTRIES) {
const oldest = health.keys().next().value;
health.delete(oldest);
}
const prev = health.get(cameraId) || {};
health.set(cameraId, {
id: cameraId,
status: patch.status || prev.status || 'unknown',
sourceKind: patch.sourceKind || prev.sourceKind || 'unknown',
label: patch.label || prev.label || '',
message: patch.message || prev.message || '',
updatedAt: Date.now(),
});
};
/** Snapshot all camera health entries as an array. */
const listHealth = () => Array.from(health.values());
/** Build a JSON payload describing stream info (feedType, URLs) for a camera. */
const buildStreamPayload = (source, cameraId) => {
const feedType = normalizeFeedType(source?.feedType || 'image');
return {
id: cameraId,
feedType,
mediaUrl: isVideoFeedType(feedType)
? `/api/cctv/media/${encodeURIComponent(cameraId)}`
: null,
frameUrl: `/api/cctv/frame/${encodeURIComponent(cameraId)}`,
provider: source?.provider || '',
sourceKind:
source?.sourceKind || (source?.url ? 'configured' : 'fallback'),
};
};
/**
* Fetch a Google Street View static image as a fallback frame. Server-side
* call, never reaches the browser — prefers GOOGLE_MAPS_SERVER_API_KEY
* (#33: a key scoped to Street View Static/Places, restricted by server IP
* rather than HTTP referrer) and falls back to the browser-exposed
* GOOGLE_MAPS_API_KEY for setups that haven't split the two yet.
*/
const streetViewFallback = async ({ lat, lon, heading, fov, pitch }) => {
const streetViewKey = googleServerApiKey();
if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon))
return null;
try {
const sv = new URL('https://maps.googleapis.com/maps/api/streetview');
sv.searchParams.set('size', '960x540');
sv.searchParams.set('location', `${lat},${lon}`);
sv.searchParams.set(
'heading',
String(Number.isFinite(heading) ? heading : 0),
);
sv.searchParams.set(
'fov',
String(Number.isFinite(fov) ? Math.max(20, Math.min(120, fov)) : 80),
);
sv.searchParams.set(
'pitch',
String(Number.isFinite(pitch) ? Math.max(-40, Math.min(20, pitch)) : 0),
);
sv.searchParams.set('source', 'outdoor');
sv.searchParams.set('return_error_code', 'true');
sv.searchParams.set('key', streetViewKey);
const svResp = await fetch(sv.toString(), {
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS),
});
const svType = svResp.headers.get('content-type') || '';
if (!svResp.ok || !svType.startsWith('image/')) return null;
return {
ok: true,
body: Buffer.from(await svResp.arrayBuffer()),
contentType: svType,
};
} catch {
return null;
}
};
const installMiddleware = (server) => {
server.middlewares.use('/api/cctv', async (req, res) => {
try {
const sources = await getCctvSources();
const sourceById = new Map(
sources.map((source) => [source.id, source]),
);
const url = new URL(req.url || '/', 'http://localhost');
if (url.pathname === '/sources') {
const body = {
sources: sources.map((source) => ({
id: source.id,
name: source.name,
city: source.city,
cityId: source.cityId,
provider: source.provider,
lat: source.lat,
lon: source.lon,
headingDeg: source.headingDeg,
headingConfidence: source.headingConfidence || '',
pitchDeg: source.pitchDeg,
fovDeg: source.fovDeg,
rangeM: source.rangeM,
mountHeightM: source.mountHeightM,
groundElevationM: source.groundElevationM,
feedType: normalizeFeedType(source.feedType),
sourceKind:
source.sourceKind || (source.url ? 'configured' : 'fallback'),
poseSource: source.poseSource,
license: source.license,
credit: source.credit || '',
code: source.code || '',
groundHeights: source.groundHeights || null,
})),
};
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(body));
return;
}
if (url.pathname === '/health') {
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ cameras: listHealth() }));
return;
}
if (url.pathname.startsWith('/stream/')) {
const cameraId =
decodeURIComponent(url.pathname.replace('/stream/', '').trim()) ||
'camera';
const source = sourceById.get(cameraId);
const payload = buildStreamPayload(source, cameraId);
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(payload));
return;
}
if (url.pathname.startsWith('/media/')) {
const cameraId =
decodeURIComponent(url.pathname.replace('/media/', '').trim()) ||
'camera';
const source = sourceById.get(cameraId);
const mediaUrl = source?.url || '';
const feedType = normalizeFeedType(source?.feedType || 'image');
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'fallback',
label: source?.provider || 'No upstream URL',
message: 'No stream URL configured',
});
res.writeHead(404, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: 'No media URL configured for this camera',
}),
);
return;
}
try {
const upstreamHeaders = {
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
};
const requestRange = req.headers?.range;
if (requestRange) upstreamHeaders.Range = requestRange;
const upstream = await fetchCctvMediaUpstream(mediaUrl, {
headers: upstreamHeaders,
});
const contentType = upstream.headers.get('content-type') || '';
if (!upstream.ok) {
try {
await upstream.body?.cancel();
} catch {
/* already closed */
}
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
label: source?.provider || 'Configured source',
message: `Upstream HTTP ${upstream.status}`,
});
res.writeHead(upstream.status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: `Upstream returned ${upstream.status}`,
}),
);
return;
}
if (
isVideoFeedType(feedType) &&
!(
contentType.startsWith('video/') ||
contentType.includes('mpegurl')
)
) {
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
label: source?.provider || 'Configured source',
message: `Unexpected media type ${contentType || 'unknown'}`,
});
} else {
setHealth(cameraId, {
status: 'ok',
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
label: source?.provider || 'Configured source',
message: isVideoFeedType(feedType)
? 'Live stream connected'
: 'Snapshot feed connected',
});
}
await proxyMediaResponse(res, upstream, {
sourceHeader: isVideoFeedType(feedType)
? 'live-media'
: 'upstream-image',
});
return;
} catch (error) {
const timedOut =
error?.name === 'AbortError' || error?.name === 'TimeoutError';
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
label: source?.provider || 'Configured source',
message: error?.message || 'Media fetch failed',
});
res.writeHead(timedOut ? 504 : 502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: timedOut
? 'Upstream media timeout'
: 'Media proxy failed',
}),
);
return;
}
}
if (!url.pathname.startsWith('/frame/')) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
return;
}
const cameraId =
decodeURIComponent(url.pathname.replace('/frame/', '').trim()) ||
'camera';
const source = sourceById.get(cameraId);
const label = url.searchParams.get('label') || source?.name || cameraId;
const city = url.searchParams.get('city') || source?.city || '';
const lat = Number(url.searchParams.get('lat') || source?.lat);
const lon = Number(url.searchParams.get('lon') || source?.lon);
const heading = Number(
url.searchParams.get('heading') || source?.headingDeg,
);
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg);
// Only use server-registered upstream URLs — never accept client-supplied URLs
// (prevents SSRF via ?upstream= query parameter)
const upstreamCandidate =
source?.snapshotUrl ||
(!isVideoFeedType(normalizeFeedType(source?.feedType))
? source?.url
: '');
const upstreamImage =
source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate);
if (upstreamImage?.ok) {
setHealth(cameraId, {
status: 'ok',
sourceKind: 'snapshot',
label: source?.provider || 'Configured source',
message: 'Upstream snapshot active',
});
res.writeHead(200, {
'Content-Type': upstreamImage.contentType,
'Cache-Control': 'no-store',
'X-CCTV-Source': 'upstream-image',
});
res.end(upstreamImage.body);
return;
}
const sv = await streetViewFallback({
lat,
lon,
heading,
fov,
pitch,
});
if (sv?.ok) {
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'streetview',
label: 'Google Street View',
message: 'Fallback Street View frame',
});
res.writeHead(200, {
'Content-Type': sv.contentType,
'Cache-Control': 'no-store',
'X-CCTV-Source': 'streetview',
});
res.end(sv.body);
return;
}
const svg = buildSyntheticCctvSvg({
cameraId,
label,
city,
status: source?.url
? 'UPSTREAM UNAVAILABLE'
: 'NO UPSTREAM CONFIGURED',
});
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'synthetic',
label: source?.provider || 'Synthetic fallback',
message: source?.url
? 'Upstream unavailable'
: 'No source configured',
});
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store',
'X-CCTV-Source': 'synthetic',
});
res.end(svg);
} catch (error) {
console.error('[CCTV Proxy]', error?.message || String(error));
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'CCTV proxy error' }));
}
});
};
return {
name: 'cctv-proxy',
configureServer: installMiddleware,
configurePreviewServer: installMiddleware,
};
}
+82
View File
@@ -0,0 +1,82 @@
import {
CCTV_MAX_SOURCES_CEILING,
DEFAULT_CCTV_MAX_SOURCES,
} from './constants.js';
/**
* Resolve the catalog-wide source cap from an env value.
*
* @param {string|number|undefined} raw - CCTV_MAX_SOURCES as configured.
* @returns {number} Cap bounded to [8, CCTV_MAX_SOURCES_CEILING].
*/
export function resolveCatalogCap(raw) {
const parsed = Number(raw || DEFAULT_CCTV_MAX_SOURCES);
if (!Number.isFinite(parsed)) return DEFAULT_CCTV_MAX_SOURCES;
return Math.max(8, Math.min(CCTV_MAX_SOURCES_CEILING, Math.floor(parsed)));
}
/**
* Merge source packs under one cap without starving any of them.
*
* Every pack arrives already ordered by its own priority (nearest-to-anchor
* first) and already trimmed to its own per-pack cap. When the packs together
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
* each pack per turn, in each pack's own order — so a lowered
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
* file/env overrides keep replacing live records; the record is then counted
* against the pack that won it.
*
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
* packs in merge order (earlier packs lose duplicate ids to later ones).
* @param {number} maxCount - Catalog cap.
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
*/
export function allocateSourceCap(packs, maxCount) {
const owner = new Map();
for (const pack of packs) {
for (const source of pack.sources) {
if (!source?.id) continue;
owner.set(source.id, { pack: pack.name, source });
}
}
const lanes = packs.map((pack) => ({
name: pack.name,
// Only the record that WON its id (the last occurrence anywhere, so also
// the last within this pack) takes a lane slot; earlier duplicates never
// count against the cap.
queue: pack.sources.filter(
(source) => source?.id && owner.get(source.id)?.source === source,
),
next: 0,
kept: 0,
}));
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
const sources = [];
if (limit >= total) {
for (const lane of lanes) {
sources.push(...lane.queue);
lane.kept = lane.queue.length;
}
} else {
while (sources.length < limit) {
for (const lane of lanes) {
if (sources.length >= limit) break;
if (lane.next >= lane.queue.length) continue;
sources.push(lane.queue[lane.next]);
lane.next += 1;
lane.kept += 1;
}
}
}
return {
sources,
packs: lanes.map((lane) => ({
name: lane.name,
offered: lane.queue.length,
kept: lane.kept,
})),
};
}
+249
View File
@@ -0,0 +1,249 @@
import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { normalizeSourceItem } from './normalize.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
loadTflSourcesFromOpenData,
loadOntarioSourcesFromOpenData,
loadFintrafficSourcesFromOpenData,
loadDriveBcSourcesFromOpenData,
loadTxdotSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTarkteeSourcesFromDatex,
loadWarendorfSourcesFromCatalog,
loadNswSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
/**
* Live open-data packs, in merge order. Adding a region is one entry here
* plus its loader in sources.js; the catalog cap is shared across entries
* round-robin (cap.js), so a new pack never silently evicts an older one.
* Each pack fails independently (allSettled) and is gated by its own env
* kill switch.
*/
const LIVE_PACKS = [
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
{
name: 'caltrans',
enabled: () => true,
load: loadCaltransSourcesFromOpenData,
},
{
name: 'tfl',
enabled: () => envEnabled('CCTV_TFL_ENABLED'),
load: loadTflSourcesFromOpenData,
},
{
name: 'ontario',
enabled: () => envEnabled('CCTV_ONTARIO_ENABLED'),
load: loadOntarioSourcesFromOpenData,
},
{
name: 'fintraffic',
enabled: () => envEnabled('CCTV_FINTRAFFIC_ENABLED'),
load: loadFintrafficSourcesFromOpenData,
},
{
name: 'drivebc',
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
load: loadDriveBcSourcesFromOpenData,
},
{
name: 'txdot',
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
load: loadTxdotSourcesFromOpenData,
},
{
name: 'tallinn',
enabled: () => envEnabled('CCTV_TALLINN_ENABLED'),
load: loadTallinnSourcesFromCatalog,
},
{
name: 'tarktee',
enabled: () => envEnabled('CCTV_TARKTEE_ENABLED'),
load: loadTarkteeSourcesFromDatex,
},
{
name: 'warendorf',
enabled: () => envEnabled('CCTV_WARENDORF_ENABLED'),
load: loadWarendorfSourcesFromCatalog,
},
{
name: 'nsw',
enabled: () => envEnabled('CCTV_NSW_ENABLED'),
load: loadNswSourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
*
* @returns {Array<object>} Array of raw source objects, or [] on error.
*/
function loadSourcesFromFile(sourceRoot) {
const sourceFile = process.env.CCTV_SOURCES_FILE || DEFAULT_CCTV_SOURCE_FILE;
const resolved = path.isAbsolute(sourceFile)
? sourceFile
: path.resolve(sourceRoot, sourceFile);
try {
if (!fs.existsSync(resolved)) return [];
const raw = fs.readFileSync(resolved, 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.warn(
'[CCTV] failed to read source file:',
resolved,
error?.message || error,
);
return [];
}
}
/**
* Load CCTV sources from the CCTV_SOURCES_JSON env variable (inline JSON).
*
* @returns {Array<object>} Array of raw source objects, or [] if unset/invalid.
*/
function loadSourcesFromEnv() {
const raw = process.env.CCTV_SOURCES_JSON;
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** Create an independent catalog rooted in the consuming application. */
export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/** @type {Array<object>} Cached merged + normalized CCTV source list. */
let _cctvSourceCache = [];
/** @type {number} Epoch-ms when the source cache was last refreshed. */
let _cctvSourceCacheAt = 0;
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
* callers so a post-TTL burst launches ONE refetch, not one per request. */
let _cctvSourceInflight = null;
/**
* Assemble and cache the merged CCTV source list.
*
* Merges every source pack (live open-data packs, local file, env
* variable), deduplicates by ID, shares the catalog cap fairly across
* packs, and caches for CCTV_SOURCE_CACHE_MS.
*
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
*/
async function getCctvSources() {
const now = Date.now();
if (
_cctvSourceCache.length &&
now - _cctvSourceCacheAt <= CCTV_SOURCE_CACHE_MS
) {
return _cctvSourceCache;
}
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
// instead of each launching the full multi-provider refetch. The `.finally`
// clears the ref so the next post-TTL cycle starts fresh.
if (_cctvSourceInflight) return _cctvSourceInflight;
_cctvSourceInflight = refreshCctvSources().finally(() => {
_cctvSourceInflight = null;
});
return _cctvSourceInflight;
}
/**
* Assemble and cache the merged CCTV source list from file/env + live packs.
* Always resolves (loaders self-catch to []); on a fully-empty refresh with a
* good prior catalog it serves stale rather than blanking the CCTV layer.
*
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
*/
async function refreshCctvSources() {
const fromFile = loadSourcesFromFile(sourceRoot);
const fromEnv = loadSourcesFromEnv();
const forceAustin =
String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1';
const preferAustin =
String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0';
// Live open-data packs load unless a file/env pack is configured and live
// packs aren't forced — the same gate that governed the Austin-only fetch
// now governs every entry in LIVE_PACKS.
const needsLiveSources =
forceAustin || (fromFile.length + fromEnv.length === 0 && preferAustin);
const liveResults = needsLiveSources
? await Promise.allSettled(
// Invoked inside the promise so a loader that throws synchronously
// (a file-based pack on a malformed row) is isolated like any other
// failed pack instead of rejecting the whole refresh.
LIVE_PACKS.map((pack) =>
Promise.resolve().then(() =>
pack.enabled() ? pack.load({ sourceRoot }) : [],
),
),
)
: [];
// Live packs first so file/env overrides win on duplicate IDs; each pack
// keeps its own priority order and the catalog cap is shared fairly.
const normalizePack = (name, items) => ({
name,
sources: items
.filter((item) => item && typeof item === 'object')
.map((item) => normalizeSourceItem(item))
.filter((item) => item.id),
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
normalizePack(
pack.name,
liveResults[index]?.status === 'fulfilled'
? liveResults[index].value
: [],
),
),
normalizePack('file', fromFile),
normalizePack('env', fromEnv),
];
const maxCount = resolveCatalogCap(process.env.CCTV_MAX_SOURCES);
const allocation = allocateSourceCap(packs, maxCount);
// Shipped ground heights (src/data/local_data/cctv_ground_heights/, produced by
// scripts/precompute-cctv-heights.mjs) ride along on the served source so
// the client can place a camera and its monitor plane with zero sampling.
const capped = joinGroundHeights(
allocation.sources,
loadGroundHeights(sourceRoot),
);
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
if (trimmed.length) {
const detail = trimmed
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
.join(', ');
console.warn(
`[CCTV] source catalog exceeds cap ${maxCount}; shared round-robin across packs (${detail}). Raise CCTV_MAX_SOURCES or lower a per-pack cap to change the mix.`,
);
}
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
} else {
// Every source came back empty (all live packs timed out / upstream outage)
// but a good catalog is already cached — serve it stale rather than blanking
// every CCTV route. Advancing the timestamp waits one TTL before retrying,
// which (with single-flight) bounds load on a persistently-down upstream.
console.warn(
`[CCTV] source refresh returned empty; serving ${_cctvSourceCache.length} stale cameras`,
);
}
_cctvSourceCacheAt = Date.now();
return _cctvSourceCache;
}
return getCctvSources;
}
+241
View File
@@ -0,0 +1,241 @@
export const DEFAULT_CCTV_SOURCE_FILE = 'config/cctv_sources.austin.json';
/** Austin Open Data portal endpoint for traffic camera records. */
export const DEFAULT_AUSTIN_ROWS_URL =
'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD';
/** Default cap on Austin cameras after distance-based prioritization. */
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
/**
* Catalog-wide safety ceiling on served cameras. Each pack already caps
* itself (nearest-to-anchor first); this bound only matters when the packs
* together exceed it, and it is then filled round-robin across packs (see
* cap.js) so no region is silently dropped. Sized above the sum of the
* default per-pack caps so a default install never trims.
*/
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
export const CCTV_MAX_SOURCES_CEILING = 5000;
/** Reference point for Austin camera prioritization (Congress & 6th). */
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
/** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */
export const TXDOT_ORIGIN = 'https://its.txdot.gov';
export const TXDOT_CCTV_STATUS_URL = (district) =>
`${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`;
/** Per-camera frame. Returns JSON `{snippet:<base64 jpeg>}`, not an image body;
* media.js decodes it (fetchTxdotSnapshot) and only for this origin. */
export const TXDOT_CCTV_SNAPSHOT_URL = `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvSnapshotByIcdId`;
/** Valid TxDOT district codes (the ITS map's own districtCodes list). */
export const TXDOT_DISTRICTS = new Set([
'ABL',
'AMA',
'ATL',
'AUS',
'BMT',
'BWD',
'BRY',
'CHS',
'CRP',
'DAL',
'ELP',
'FTW',
'HOU',
'LRD',
'LBB',
'LFK',
'ODA',
'PAR',
'PHR',
'SJT',
'SAT',
'TYL',
'WAC',
'WFS',
'YKM',
]);
/** Districts fetched by default: Austin (the reference camera city) and San
* Antonio. A statewide default was rejected: per-pack prioritization is
* nearest-to-any-anchor, so Dallas's dense core would take most slots.
* CCTV_TXDOT_DISTRICTS opens up the rest ("AUS,SAT,HOU,DAL,FTW" for the five
* big metros, or any of the 25 codes). */
export const DEFAULT_TXDOT_DISTRICTS = 'AUS,SAT';
export const DEFAULT_TXDOT_MAX_SOURCES = 500;
/** Prioritization anchors: downtown cores of the metro districts a user can
* select. Cameras rank by distance to the NEAREST anchor, so a widened
* CCTV_TXDOT_DISTRICTS still ranks sensibly instead of against Austin alone. */
export const TXDOT_ANCHORS = [
{ lat: 30.2672, lon: -97.7431 }, // Austin
{ lat: 29.4241, lon: -98.4936 }, // San Antonio
{ lat: 29.7604, lon: -95.3698 }, // Houston
{ lat: 32.7767, lon: -96.797 }, // Dallas
{ lat: 32.7555, lon: -97.3308 }, // Fort Worth
];
/** Ground-elevation priors in metres, by district. The TxDOT payload carries
* no elevation, and on a keyless (no-tileset) stack the client's ground snap
* never fires, so this prior is the only height a camera gets there. Texas
* spans sea level (Houston) to ~1,140 m (El Paso). */
export const TXDOT_DISTRICT_ELEVATION_M = Object.freeze({
ABL: 520,
AMA: 1099,
ATL: 105,
AUS: 149,
BMT: 5,
BWD: 425,
BRY: 111,
CHS: 250,
CRP: 7,
DAL: 131,
ELP: 1140,
FTW: 199,
HOU: 15,
LRD: 132,
LBB: 992,
LFK: 91,
ODA: 890,
PAR: 185,
PHR: 30,
SJT: 585,
SAT: 198,
TYL: 165,
WAC: 143,
WFS: 289,
YKM: 70,
});
export const TXDOT_DEFAULT_ELEVATION_M = 150;
export const CALTRANS_CCTV_URL = (district) =>
`https://cwwp2.dot.ca.gov/data/d${district}/cctv/cctvStatusD${String(district).padStart(2, '0')}.json`;
/** Districts fetched by default: SF Bay (4), LA (7), San Diego (11), Sacramento (3). */
export const DEFAULT_CALTRANS_DISTRICTS = '4,7,11,3';
export const DEFAULT_CALTRANS_MAX_SOURCES = 300;
/** Prioritization anchors: downtown cores of the four default metros. */
export const CALTRANS_ANCHORS = [
{ lat: 37.7793, lon: -122.4193 }, // San Francisco
{ lat: 34.0537, lon: -118.2428 }, // Los Angeles
{ lat: 32.7157, lon: -117.1611 }, // San Diego
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
];
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
export const TFL_IMAGE_ORIGIN =
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
export const DEFAULT_TFL_MAX_SOURCES = 250;
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
export const ONTARIO_511_CAMERAS_URL =
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
export const DEFAULT_ONTARIO_MAX_SOURCES = 1000;
export const ONTARIO_ANCHORS = [
{ lat: 43.4516, lon: -80.4925 }, // Kitchener
{ lat: 43.6532, lon: -79.3832 }, // Toronto
{ lat: 45.4215, lon: -75.6972 }, // Ottawa
{ lat: 43.2557, lon: -79.8711 }, // Hamilton
{ lat: 42.9849, lon: -81.2453 }, // London, Ontario
{ lat: 42.3149, lon: -83.0364 }, // Windsor
];
/** Fintraffic road weather cameras (Digitraffic): one keyless GeoJSON list
* covering all of Finland. Each STATION carries N presets (fixed camera views)
* that share the station position; one preset is one camera here. */
export const FINTRAFFIC_STATIONS_URL =
'https://tie.digitraffic.fi/api/weathercam/v1/stations';
/** Frames: `<origin><presetId>.jpg`. Preset ids are synthesized into this
* origin rather than read from the payload, so no upstream field can steer the
* frame proxy off-host. */
export const FINTRAFFIC_IMAGE_ORIGIN = 'https://weathercam.digitraffic.fi/';
/** Digitraffic asks every client to identify itself on API calls. */
export const DIGITRAFFIC_USER = 'gods-eye-view';
export const DEFAULT_FINTRAFFIC_MAX_SOURCES = 300;
/** Ground-elevation prior, in metres, for stations that report no altitude.
* 228 of 809 stations carry a real metre value (median 94 m); the rest report
* 0, which means "not reported" rather than sea level — Kouvola (~80 m of real
* elevation) reports 0. The observed median stands in for those. */
export const FINTRAFFIC_GROUND_ELEVATION_M = 90;
/** Prioritization anchors: the population centres strung along Finland's main
* road spine (vt1 Turku, vt3 Tampere, vt4 Jyväskylä–Oulu–Rovaniemi, vt5
* Kuopio), so a cap keeps national coverage rather than just the capital. */
export const FINLAND_ANCHORS = [
{ lat: 60.1699, lon: 24.9384 }, // Helsinki
{ lat: 60.4518, lon: 22.2666 }, // Turku
{ lat: 61.4978, lon: 23.761 }, // Tampere
{ lat: 62.2426, lon: 25.7473 }, // Jyväskylä
{ lat: 62.8924, lon: 27.677 }, // Kuopio
{ lat: 65.0121, lon: 25.4651 }, // Oulu
{ lat: 66.5039, lon: 25.7294 }, // Rovaniemi
];
/** Global cap on total CCTV sources served by the proxy: the default per-pack
* caps summed (Austin 250 + Caltrans 300 + TfL 250 + DriveBC 250). */
/** DriveBC highway cameras (British Columbia): the keyless camera list served by
* the DriveBC.ca site (github.com/bcgov/DriveBC.ca). The DataBC HighwayCams CSV
* lists the same cameras but still carries retired images.drivebc.ca frame URLs,
* so frames are built from the numeric camera id on the current image host. */
export const DRIVEBC_WEBCAMS_URL = 'https://www.drivebc.ca/api/webcams/';
export const DRIVEBC_IMAGE_URL = (id) =>
`https://www.drivebc.ca/images/${id}.jpg`;
export const DEFAULT_DRIVEBC_MAX_SOURCES = 250;
/** Prioritization anchors: downtown Vancouver and Victoria. */
export const DRIVEBC_ANCHORS = [
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
{ lat: 48.4284, lon: -123.3656 }, // Victoria
];
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
export const TALLINN_IMAGE_ORIGIN = 'https://ristmikud.tallinn.ee/';
export const TALLINN_CENTER = { lat: 59.437, lon: 24.753 };
/** Transpordiamet / Tarktee road-weather cameras: keyless DATEX2 feeds. */
export const TARKTEE_LOCATIONS_URL =
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations';
export const TARKTEE_IMAGES_URL =
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
export const TARKTEE_ANCHORS = [
{ lat: 59.437, lon: 24.753 }, // Tallinn
{ lat: 58.378, lon: 26.729 }, // Tartu
{ lat: 58.3859, lon: 24.4971 }, // Pärnu
{ lat: 59.3797, lon: 28.1791 }, // Narva
];
/** Warendorf (Germany): the Marktplatz municipal webcam from a curated catalog file. */
export const DEFAULT_WARENDORF_SOURCE_FILE =
'config/cctv_sources.warendorf.json';
export const WARENDORF_IMAGE_ORIGINS = Object.freeze([
'http://webcam.warendorf.de/',
'https://www.kreis-warendorf.de/',
]);
/** Live Traffic NSW (Transport for NSW): keyless public camera catalog. */
export const NSW_CAMERAS_URL =
'https://data.livetraffic.com/cameras/traffic-cam.json';
export const NSW_IMAGE_ORIGIN = 'https://webcams.transport.nsw.gov.au/';
export const DEFAULT_NSW_MAX_SOURCES = 250;
export const SYDNEY_CENTER = { lat: -33.8688, lon: 151.2093 };
/**
* The NSW webcam host answers non-browser clients with HTTP 200 and a short
* HTML body instead of the frame (verified 2026-09-13), so the proxy
* identifies as a browser for that one host. See media.js.
*/
export const NSW_IMAGE_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
/**
* Longest NSW `view` sentence still usable as a label. NSW occasionally
* repurposes `view` for a multi-paragraph works notice; real descriptions top
* out around 120 characters.
*/
export const NSW_MAX_VIEW_LABEL = 140;
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
* stalled upstream can't leave getCctvSources (and thus every CCTV route)
* pending forever — a hung fetch aborts, the loader returns [], and
* serve-stale/other packs take over. */
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
/** Individual CCTV image fetches must settle before the active 10-second
* client refresh cadence. A bounded miss can fall through to Street View or
* the synthetic frame instead of leaving the browser preview pending. */
export const CCTV_FRAME_FETCH_TIMEOUT_MS = 8 * 1000;
/** Maximum buffered snapshot size. */
export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024;
/** Deadline for upstream response headers; live bodies keep streaming afterward. */
export const CCTV_MEDIA_FETCH_TIMEOUT_MS = 15 * 1000;
/** Declared size ceiling for fixed media responses. */
export const CCTV_MEDIA_MAX_BODY_BYTES = 64 * 1024 * 1024;
+74
View File
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import { poseHash } from '../../../src/data/cctvFootprint.js';
/** Sidecar written by scripts/precompute-cctv-heights.mjs. */
export const DEFAULT_GROUND_HEIGHTS_FILE =
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json';
/** @type {{path:string, mtimeMs:number, cameras:object}|null} */
let _cache = null;
/**
* Load the precomputed ground-height sidecar (heights under each camera's mount
* and monitor-plane support points, aligned to the Google 3D Tiles surface). Cached by file mtime so a
* catalog refresh re-reads only when the file changed. Missing or malformed
* files mean "no shipped heights", never an error.
* @param {string} sourceRoot
* @returns {Record<string, object>} camera id → sidecar entry
*/
export function loadGroundHeights(sourceRoot = process.cwd()) {
const file =
process.env.CCTV_GROUND_HEIGHTS_FILE || DEFAULT_GROUND_HEIGHTS_FILE;
const resolved = path.isAbsolute(file)
? file
: path.resolve(sourceRoot, file);
try {
const stat = fs.statSync(resolved);
if (_cache && _cache.path === resolved && _cache.mtimeMs === stat.mtimeMs) {
return _cache.cameras;
}
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
const cameras =
parsed &&
typeof parsed === 'object' &&
parsed.cameras &&
typeof parsed.cameras === 'object'
? parsed.cameras
: {};
_cache = { path: resolved, mtimeMs: stat.mtimeMs, cameras };
return cameras;
} catch {
_cache = null;
return {};
}
}
/**
* Attach a shipped entry to each served source whose id has one AND whose
* nominal pose still hashes to what the entry was sampled for. A pack whose
* feed moved a camera or changed its pose simply loses the shipped value and
* falls back to runtime placement.
* @param {Array<object>} sources - Normalized served sources.
* @param {Record<string, object>} entries - Sidecar cameras map.
* @returns {Array<object>} The same sources, `groundHeights` attached where valid.
*/
export function joinGroundHeights(sources, entries) {
if (!entries || typeof entries !== 'object') return sources;
for (const source of sources) {
const entry = entries[source.id];
if (!entry || entry.status !== 'ok' || !Number.isFinite(entry.mountGroundM))
continue;
if (entry.poseHash !== poseHash(source)) continue;
const supports = {};
for (const [key, value] of Object.entries(entry.supports || {})) {
if (Number.isFinite(value)) supports[key] = value;
}
source.groundHeights = {
poseHash: entry.poseHash,
mountGroundM: entry.mountGroundM,
supports,
};
}
return sources;
}
+453
View File
@@ -0,0 +1,453 @@
import { Readable } from 'node:stream';
import { hashSeed, escapeXml } from './normalize.js';
import {
CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_FRAME_MAX_BODY_BYTES,
CCTV_MEDIA_FETCH_TIMEOUT_MS,
CCTV_MEDIA_MAX_BODY_BYTES,
NSW_IMAGE_ORIGIN,
NSW_IMAGE_USER_AGENT,
} from './constants.js';
/**
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
*
* Produces a 960x540 SVG with a deterministic gradient (hue derived from
* camera ID hash), scanline overlay, HUD-style grid, and text labels
* showing camera name, city, ID, status, and current timestamp. Used
* when no upstream image or Street View fallback is available.
*
* @param {object} opts
* @param {string} opts.cameraId
* @param {string} opts.label
* @param {string} [opts.city]
* @param {string} [opts.status]
* @returns {string} SVG markup string.
*/
export function buildSyntheticCctvSvg({ cameraId, label, city, status }) {
const seed = hashSeed(`${cameraId}:${label}:${city}`);
const hue = seed % 360;
const hue2 = (hue + 46) % 360;
const now = new Date();
const ts = now.toISOString().replace('T', ' ').replace('Z', 'Z').slice(0, 20);
const safeLabel = escapeXml(label);
const safeCity = escapeXml(city || 'GLOBAL GRID');
const safeId = escapeXml(cameraId);
const safeStatus = escapeXml(status || 'SYNTHETIC');
return `
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="hsl(${hue}, 35%, 10%)" />
<stop offset="60%" stop-color="hsl(${hue2}, 42%, 6%)" />
<stop offset="100%" stop-color="#020509" />
</linearGradient>
<radialGradient id="flare" cx="0.22" cy="0.24" r="0.78">
<stop offset="0%" stop-color="hsla(${hue2}, 100%, 65%, 0.35)" />
<stop offset="100%" stop-color="hsla(${hue2}, 100%, 40%, 0)" />
</radialGradient>
<pattern id="scan" width="8" height="8" patternUnits="userSpaceOnUse">
<rect width="8" height="8" fill="transparent" />
<rect y="0" width="8" height="1" fill="rgba(255,255,255,0.08)" />
<rect y="4" width="8" height="1" fill="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect width="960" height="540" fill="url(#bg)" />
<rect width="960" height="540" fill="url(#flare)" />
<rect width="960" height="540" fill="url(#scan)" />
<g stroke="rgba(123,233,255,0.25)" stroke-width="1" fill="none">
<path d="M60 460 Q300 300 520 420 T900 320" />
<path d="M100 160 Q340 40 620 130 T920 90" />
<path d="M20 280 Q220 230 390 270 T760 250" />
</g>
<g fill="none" stroke="rgba(180,248,255,0.2)" stroke-width="1">
<rect x="70" y="80" width="820" height="380" rx="8" />
<line x1="70" y1="270" x2="890" y2="270" />
<line x1="480" y1="80" x2="480" y2="460" />
</g>
<g fill="#9cefff" font-family="JetBrains Mono, monospace" text-transform="uppercase">
<text x="74" y="54" font-size="16" letter-spacing="2">CCTV FEED PLACEHOLDER</text>
<text x="74" y="512" font-size="14" letter-spacing="1.5">${safeLabel} · ${safeCity}</text>
<text x="646" y="512" font-size="13" letter-spacing="1.2">${safeId}</text>
<text x="704" y="54" font-size="15" letter-spacing="2">${escapeXml(ts)}</text>
<text x="74" y="486" font-size="13" letter-spacing="1.3">${safeStatus}</text>
</g>
</svg>`.trim();
}
/**
* Coerce a fetch() response body to a Node.js Readable stream.
*
* Handles both Node-native streams (.pipe) and web ReadableStreams (.getReader).
*
* @param {ReadableStream|NodeJS.ReadableStream|null} body
* @returns {import('stream').Readable|null}
*/
export function toReadable(body) {
if (!body) return null;
if (typeof body.pipe === 'function') return body;
if (typeof body.getReader === 'function') {
return Readable.fromWeb(body);
}
return null;
}
/**
* Pipe an upstream fetch Response (image or video) to the client HTTP response.
*
* Forwards Content-Type, Content-Length, Content-Range, Accept-Ranges, and
* Cache-Control headers from the upstream. Falls back to buffered arrayBuffer
* if the body is not streamable.
*
* @param {import('http').ServerResponse} res
* @param {Response} upstream - fetch() Response object.
* @param {object} [opts]
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
*/
export async function proxyMediaResponse(
res,
upstream,
{ sourceHeader = 'upstream' } = {},
) {
const contentType =
upstream.headers.get('content-type') || 'application/octet-stream';
const cacheControl = upstream.headers.get('cache-control') || 'no-store';
const contentLength = upstream.headers.get('content-length');
const contentRange = upstream.headers.get('content-range');
const acceptRanges = upstream.headers.get('accept-ranges');
const headers = {
'Content-Type': contentType,
'Cache-Control': cacheControl,
'X-CCTV-Source': sourceHeader,
};
if (contentLength) headers['Content-Length'] = contentLength;
if (contentRange) headers['Content-Range'] = contentRange;
if (acceptRanges) headers['Accept-Ranges'] = acceptRanges;
// Cheap defense: reject an upstream that DECLARES an oversized fixed body.
// Live MJPEG/HLS streams are unbounded by design and send no content-length,
// so they pipe normally (piping streams to the client, never buffering).
if (
Number.isFinite(Number(contentLength)) &&
Number(contentLength) > CCTV_MEDIA_MAX_BODY_BYTES
) {
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Upstream media exceeds size cap' }));
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
return;
}
res.writeHead(upstream.status, headers);
const stream = toReadable(upstream.body);
if (!stream) {
const buf = Buffer.from(await upstream.arrayBuffer());
res.end(buf);
return;
}
stream.on('error', () => {
if (!res.writableEnded) res.end();
});
stream.pipe(res);
}
/** Read a snapshot incrementally, retaining at most maxBytes of owned chunks. */
async function readCappedResponseBytes(upstream, maxBytes) {
const declared = Number(upstream.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
return null;
}
if (!upstream.body) return null;
const chunks = [];
let total = 0;
// Every retained chunk is an OWNED copy: a chunk can be a small view over a
// much larger backing ArrayBuffer, and keeping the view would retain that
// whole allocation while the byte accounting only counted the view.
const keep = (chunk) => {
total += chunk.byteLength;
if (total > maxBytes) return false;
chunks.push(Buffer.from(chunk));
return true;
};
if (typeof upstream.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of upstream.body) {
if (!keep(chunk)) {
try {
await upstream.body.cancel();
} catch {
/* no-op */
}
return null;
}
}
return Buffer.concat(chunks, total);
}
// No async iterator: stream through a reader so the cap still applies while
// reading. A body that cannot be streamed at all is refused rather than
// buffered uncapped — the helper's whole contract is the cap.
const reader =
typeof upstream.body.getReader === 'function'
? upstream.body.getReader()
: null;
if (!reader) return null;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!keep(value)) {
try {
await reader.cancel();
} catch {
/* already closed */
}
return null;
}
}
return Buffer.concat(chunks, total);
} finally {
reader.releaseLock();
}
}
/** Open registered media within a header deadline; leave timely live bodies running. */
export async function fetchCctvMediaUpstream(
url,
{
headers = {},
fetchImpl = fetch,
timeoutMs = CCTV_MEDIA_FETCH_TIMEOUT_MS,
} = {},
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(url, { headers, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
/**
* Fetch and decode a TxDOT ITS / TransGuide snapshot.
*
* TxDOT returns JSON with a base64-encoded JPEG in `snippet`, rather than
* returning image/jpeg directly.
*/
export async function fetchTxdotSnapshot(
url,
{
fetchImpl = fetch,
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
} = {},
) {
if (!url) return null;
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (
parsed.origin !== 'https://its.txdot.gov' ||
parsed.pathname !== '/its/DistrictIts/GetCctvSnapshotByIcdId'
) {
return null;
}
// Base64 inflates by 4/3; the JSON envelope adds a few bytes of framing.
const maxEnvelopeBytes = Math.ceil((maxBytes * 4) / 3) + 4096;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
// The snapshot endpoint answers directly; a redirect is not followed, so
// the origin/path pin above holds for the request that is actually made.
const upstream = await fetchImpl(parsed.toString(), {
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
},
signal: controller.signal,
redirect: 'manual',
});
if (!upstream.ok) return null;
const envelope = await readCappedResponseBytes(upstream, maxEnvelopeBytes);
if (!envelope) return null;
let payload;
try {
payload = JSON.parse(envelope.toString('utf8'));
} catch {
return null;
}
let snippet =
typeof payload?.snippet === 'string' ? payload.snippet.trim() : '';
if (!snippet) return null;
snippet = snippet.replace(/^data:image\/jpeg;base64,/i, '');
// Canonical base64 only (4-char groups, padding only at the end):
// Buffer.from() silently skips junk, which would let a non-image body
// decode into "something".
if (
snippet.length > maxEnvelopeBytes ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
snippet,
)
) {
return null;
}
const body = Buffer.from(snippet, 'base64');
if (body.length < 4 || body.length > maxBytes) return null;
if (body[0] !== 0xff || body[1] !== 0xd8 || body[2] !== 0xff) return null;
return { ok: true, body, contentType: 'image/jpeg' };
} catch {
return null;
} finally {
clearTimeout(timeoutId);
controller.abort();
}
}
/** Redirect hops the frame path will follow, and only within the same host. */
const MAX_SAME_HOST_REDIRECTS = 2;
/**
* Fetch a registered frame URL following redirects ONLY within the original
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops).
* Default redirect-following would let an upstream steer a host-pinned
* request, and its host-specific headers, to any origin, another port, or a
* plaintext downgrade.
*
* @param {string} url
* @param {object} init - fetch init (headers, signal).
* @param {typeof fetch} fetchImpl
* @returns {Promise<Response|null>} Final response, or null on an off-host or
* over-long redirect chain.
*/
export async function fetchWithinHost(url, init, fetchImpl = fetch) {
let current;
try {
current = new URL(url);
} catch {
return null;
}
const origin = current.origin;
for (let hop = 0; hop <= MAX_SAME_HOST_REDIRECTS; hop++) {
const upstream = await fetchImpl(current.toString(), {
...init,
redirect: 'manual',
});
// Anything that is not a 3xx (including a test double with no status) is
// the final answer.
const status = Number(upstream?.status);
if (!(status >= 300 && status < 400)) return upstream;
const location = upstream.headers.get('location');
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
if (!location || hop === MAX_SAME_HOST_REDIRECTS) return null;
let next;
try {
next = new URL(location, current);
} catch {
return null;
}
if (next.origin !== origin) return null;
current = next;
}
return null;
}
/**
* Image hosts that only serve frames to browser-identified clients, keyed by
* exact hostname. Every other upstream sees the proxy's own identifying
* User-Agent. Keyed on host, not on a URL substring, so a look-alike host or a
* path that merely mentions the host never inherits the header.
*/
const CCTV_IMAGE_USER_AGENT_BY_HOST = Object.freeze({
[new URL(NSW_IMAGE_ORIGIN).hostname]: NSW_IMAGE_USER_AGENT,
});
/**
* User-Agent for one upstream frame request.
*
* @param {string} url
* @returns {string}
*/
export function cctvUpstreamUserAgent(url) {
try {
return (
CCTV_IMAGE_USER_AGENT_BY_HOST[new URL(url).hostname] ||
'gods-eye-view-cctv-proxy/1.0'
);
} catch {
return 'gods-eye-view-cctv-proxy/1.0';
}
}
/**
* Fetch one upstream CCTV image within the frame-refresh budget.
*
* A timeout is treated like every other upstream miss so the caller can
* continue through the Street View and synthetic fallback chain. `fetchImpl`
* and `timeoutMs` are injectable only to keep the timeout contract unit-testable.
*
* @param {string} url - Server-registered upstream image URL.
* @param {object} [options]
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
*/
export async function fetchCctvImageFromUpstream(
url,
{
fetchImpl = fetch,
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
} = {},
) {
if (!url || !/^https?:\/\//i.test(url)) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(
new DOMException('CCTV upstream frame fetch timed out', 'TimeoutError'),
);
}, timeoutMs);
try {
const upstream = await fetchWithinHost(
url,
{
headers: { 'User-Agent': cctvUpstreamUserAgent(url) },
signal: controller.signal,
},
fetchImpl,
);
if (!upstream) return null;
const contentType = upstream.headers.get('content-type') || '';
if (!upstream.ok || !contentType.startsWith('image/')) {
controller.abort();
return null;
}
const body = await readCappedResponseBytes(upstream, maxBytes);
if (!body) return null;
return { ok: true, body, contentType };
} catch {
return null;
} finally {
clearTimeout(timeoutId);
controller.abort();
}
}
+533
View File
@@ -0,0 +1,533 @@
import { directionToHeading } from '../../../src/data/directionText.js';
import { haversineKm } from '../common/geo.js';
/**
* FNV-1a 32-bit hash of a string, used to derive deterministic pseudo-random
* values (e.g. hue for synthetic SVG billboards, fallback heading angles).
*
* @param {string} text
* @returns {number} Unsigned 32-bit hash.
*/
export function hashSeed(text) {
let h = 2166136261 >>> 0; // FNV offset basis
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619); // FNV prime
}
return h >>> 0;
}
/**
* Escape special XML/HTML characters for safe embedding in SVG text nodes.
*
* @param {string} text
* @returns {string}
*/
export function escapeXml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Canonicalize a CCTV feed type string to one of:
* 'image', 'mjpeg', 'mp4', 'webm', 'hls', or pass-through.
*
* @param {string} value - Raw feed type (e.g. 'jpeg', 'mjpg', 'video', 'stream').
* @returns {string} Normalized feed type.
*/
export function normalizeFeedType(value) {
const raw = String(value || '')
.trim()
.toLowerCase();
if (!raw) return 'image';
if (raw === 'jpeg' || raw === 'jpg' || raw === 'png') return 'image';
if (raw === 'mjpg') return 'mjpeg';
if (raw === 'video') return 'mp4';
if (raw === 'stream') return 'hls';
return raw;
}
/**
* Check whether a normalized feed type represents streaming video.
*
* @param {string} feedType
* @returns {boolean}
*/
export function isVideoFeedType(feedType) {
return feedType === 'mp4' || feedType === 'webm' || feedType === 'hls';
}
/**
* Coerce a value to a finite number, returning fallback if NaN/Infinity.
*
* @param {*} value
* @param {number} [fallback=NaN]
* @returns {number}
*/
export function toFiniteNumber(value, fallback = NaN) {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
/**
* Normalize a column/field name to a lowercase snake_case key.
*
* @param {string} text
* @returns {string}
*/
export function normalizeKey(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Parse a WKT POINT string (e.g. "POINT(-97.74 30.27)") into lat/lon.
*
* WKT uses (lon lat) order; returned object uses {lat, lon}.
*
* @param {string} value
* @returns {{lat:number, lon:number}}
*/
export function parsePointString(value) {
const match = String(value || '').match(
/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i,
);
if (!match) return { lat: NaN, lon: NaN };
return {
lon: toFiniteNumber(match[1]),
lat: toFiniteNumber(match[2]),
};
}
/**
* Extract lat/lon from a variety of coordinate representations.
*
* Handles WKT POINT strings, and objects with latitude/lat/y or
* longitude/lon/lng/x properties (various casing).
*
* @param {string|object|null} value
* @returns {{lat:number, lon:number}}
*/
export function coerceLatLon(value) {
if (!value) return { lat: NaN, lon: NaN };
if (typeof value === 'string') {
return parsePointString(value);
}
if (typeof value !== 'object') {
return { lat: NaN, lon: NaN };
}
const lat = toFiniteNumber(
value.latitude ?? value.lat ?? value.y ?? value.Latitude ?? value.Lat,
NaN,
);
const lon = toFiniteNumber(
value.longitude ??
value.lon ??
value.lng ??
value.x ??
value.Longitude ??
value.Lon,
NaN,
);
return { lat, lon };
}
/**
* Extract geographic coordinates from an Austin Open Data camera record.
*
* Tries several candidate fields (location, coordinates, the_geom,
* point, geocoded_column) via coerceLatLon, then falls back to
* explicit latitude/longitude scalar fields.
*
* @param {object} record - Flattened camera record.
* @returns {{lat:number, lon:number}}
*/
export function extractAustinCoords(record) {
const candidates = [
record.location,
record.coordinates,
record.the_geom,
record.point,
record.geocoded_column,
];
for (const candidate of candidates) {
const parsed = coerceLatLon(candidate);
if (Number.isFinite(parsed.lat) && Number.isFinite(parsed.lon))
return parsed;
}
const lat = toFiniteNumber(
record.latitude ??
record.lat ??
record.camera_latitude ??
record.location_latitude,
NaN,
);
const lon = toFiniteNumber(
record.longitude ??
record.lon ??
record.lng ??
record.camera_longitude ??
record.location_longitude,
NaN,
);
return { lat, lon };
}
/**
* Extract a numeric camera ID from an Austin Open Data record.
*
* Tries well-known field names first, then scans any field whose key
* contains "camera"/"cam"/"device" + "id".
*
* @param {object} record - Flattened camera record.
* @returns {string} Numeric ID string, or '' if none found.
*/
export function extractAustinCameraId(record) {
const preferredKeys = [
'camera_id',
'cameraid',
'cam_id',
'device_id',
'intersection_id',
'id',
];
for (const key of preferredKeys) {
const value = record[key];
if (value == null) continue;
const asText = String(value).trim();
if (!asText) continue;
if (/^\d+$/.test(asText)) return asText;
}
for (const [key, value] of Object.entries(record)) {
if (!/camera|cam|device/.test(key)) continue;
if (!/id/.test(key)) continue;
const asText = String(value || '').trim();
if (!asText) continue;
if (/^\d+$/.test(asText)) return asText;
}
return '';
}
/**
* Extract a human-readable camera name from an Austin record.
*
* @param {object} record - Flattened camera record.
* @param {string} cameraId - Fallback identifier if no name field found.
* @returns {string}
*/
export function extractAustinName(record, cameraId) {
const preferredKeys = [
'camera_name',
'location_name',
'intersection_name',
'location',
'cross_street',
'description',
'name',
];
for (const key of preferredKeys) {
const value = record[key];
if (typeof value !== 'string') continue;
const text = value.trim();
if (text) return text;
}
return `Austin Camera ${cameraId}`;
}
/**
* Extract camera heading (compass bearing) from an Austin record.
*
* Tries explicit numeric heading fields first, then direction-keyword
* fields, then infers from the camera name/description text.
*
* @param {object} record - Flattened camera record.
* @returns {number} Heading in degrees [0..360), or NaN if unknown.
*/
export function extractAustinHeading(record) {
const direct = toFiniteNumber(
record.heading_deg ?? record.heading ?? record.bearing,
NaN,
);
if (Number.isFinite(direct)) return ((direct % 360) + 360) % 360;
// Dedicated direction fields: bare cardinal words ("West") are real facings.
const directionKeys = [
'direction',
'travel_direction',
'facing',
'facing_direction',
];
for (const key of directionKeys) {
const heading = directionToHeading(record[key], true);
if (Number.isFinite(heading)) return heading;
}
// Free-form name/intersection text: only explicit travel forms ("WESTBOUND"/
// "WB") count — a bare "West" here is a street name ("5TH ST / WEST AVE"), not
// a facing, and must not promote the camera to a false high-confidence heading.
const nameProbe = [
record.camera_name,
record.location_name,
record.intersection_name,
record.location,
record.cross_street,
record.description,
record.name,
]
.filter(Boolean)
.join(' ');
const inferred = directionToHeading(nameProbe);
if (Number.isFinite(inferred)) return inferred;
return NaN;
}
/**
* Bounding-box sanity check: is this coordinate plausibly in the Austin metro area?
*
* @param {number} lat
* @param {number} lon
* @returns {boolean}
*/
export function isLikelyAustinCoordinate(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= 30.02 && lat <= 30.58 && lon >= -98.12 && lon <= -97.4;
}
/**
* Bounding-box sanity check: is this coordinate plausibly on the Finnish road
* network? Generous around the observed catalog extent (59.86..70.09 N,
* 19.62..31.28 E) so a real new station is never dropped, tight enough that a
* swapped lat/lon or a null island record is.
*
* @param {number} lat
* @param {number} lon
* @returns {boolean}
*/
/** Longest unselected camera label the HUD shows before it gets noisy. */
export const CAMERA_CODE_MAX_CHARS = 28;
/**
* Short display code for the unselected camera label ("CAM-<code>"): the
* feed's own name for the camera ("5TH ST / CONGRESS AVE", "TRAFALGAR
* SQUARE"), trimmed to CAMERA_CODE_MAX_CHARS. A pack may pass an explicit
* `code` (TxDOT's device key, NSW's title); the id is the last resort.
*
* @param {string} text
* @returns {string}
*/
export function cameraDisplayCode(text) {
const clean = String(text || '')
.replace(/\s+/g, ' ')
.trim();
if (clean.length <= CAMERA_CODE_MAX_CHARS) return clean;
return `${clean.slice(0, CAMERA_CODE_MAX_CHARS - 1).trimEnd()}…`;
}
/** Finite, in range, and not the null island that Number(null) produces. */
export function isPlausibleLatLon(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180 &&
!(lat === 0 && lon === 0)
);
}
/** British Columbia bounding box (with the neighbouring border crossings). */
export function isLikelyBcCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 48 &&
lat <= 60.5 &&
lon >= -139.5 &&
lon <= -114
);
}
/** Texas bounding box. */
export function isLikelyTexasCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 25.5 &&
lat <= 36.7 &&
lon >= -107 &&
lon <= -93.4
);
}
/** New South Wales bounding box (incl. the ACT and Lord Howe Island). */
export function isLikelyNswCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= -38 &&
lat <= -28 &&
lon >= 140.9 &&
lon <= 159.2
);
}
export function isLikelyFinlandCoordinate(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
}
/**
* Human label for one Fintraffic preset (camera view).
*
* Station names are machine-shaped road codes ("vt3_Hyvinkää_Noppo"); the
* underscores become spaces. A preset id is always its station id plus a
* two-digit view number, so the remainder distinguishes the several views that
* share one station position. The station list endpoint carries no
* presentationName ("Helsinkiin"); that lives only on the per-station detail
* endpoint, which would cost one request per station.
*
* @param {string} stationName - Raw `properties.name`.
* @param {string} stationId - Raw `properties.id` (e.g. "C01503").
* @param {string} presetId - Raw preset id (e.g. "C0150301").
* @returns {string}
*/
export function fintrafficCameraName(stationName, stationId, presetId) {
const base =
String(stationName || '')
.replace(/_/g, ' ')
.trim() || `Fintraffic ${stationId}`;
const view = String(presetId || '').slice(String(stationId || '').length);
return view ? `${base} (view ${view})` : base;
}
/**
* Derive a deterministic fallback heading from a camera ID hash.
*
* Produces one of 16 evenly-spaced compass directions (0, 22.5, 45, ...).
*
* @param {string} cameraId
* @returns {number} Heading in degrees [0..360).
*/
export function fallbackHeadingFromId(cameraId) {
return (hashSeed(String(cameraId)) % 16) * 22.5;
}
/**
* Convert a Socrata rows.json array row into a keyed object using column metadata.
*
* @param {Array} row - Array of cell values from the Socrata payload.
* @param {Array<{fieldName?:string, name?:string}>} columns - Column descriptors.
* @returns {object} Keyed record with normalized snake_case keys.
*/
export function rowArrayToObject(row, columns) {
const record = {};
for (let idx = 0; idx < columns.length; idx++) {
const col = columns[idx];
const key = normalizeKey(col.fieldName || col.name || `col_${idx}`);
if (!key) continue;
record[key] = row[idx];
}
return record;
}
/**
* Distance-prioritizes cameras to a cap: keeps the maxCount cameras closest
* to ANY of the given anchor points (min distance over anchors), tie-broken
* by original array order. Used by every live source pack (Austin: one
* downtown anchor; Caltrans: one anchor per major CA metro; TfL: central
* London) so a cap always keeps the densest, most interesting cores.
*
* @param {Array<object>} cameras - Normalized camera source objects.
* @param {number} maxCount - Cap (<=0 or >= length disables).
* @param {Array<{lat:number,lon:number}>} anchors - At least one anchor.
* @returns {Array<object>} Capped, priority-ordered camera list.
*/
export function prioritizeSources(cameras, maxCount, anchors) {
const list = Array.isArray(cameras) ? cameras : [];
const anchorList = (Array.isArray(anchors) ? anchors : []).filter(
(a) => Number.isFinite(a?.lat) && Number.isFinite(a?.lon),
);
if (!anchorList.length) return list;
// Always sort when anchors exist, even when the pack fits its own cap: the
// catalog-wide cap (cap.js) thins a pack from the END of this order, so
// "nearest to an anchor first" has to hold whether or not the pack was
// trimmed here.
const cap =
Number.isFinite(maxCount) && maxCount > 0
? Math.min(maxCount, list.length)
: list.length;
const scored = list.map((camera, idx) => {
const lat = Number(camera?.lat);
const lon = Number(camera?.lon);
const distKm =
Number.isFinite(lat) && Number.isFinite(lon)
? Math.min(
...anchorList.map((a) => haversineKm(lat, lon, a.lat, a.lon)),
)
: Number.POSITIVE_INFINITY;
return { camera, idx, distKm };
});
scored.sort((a, b) => {
if (a.distKm !== b.distKm) return a.distKm - b.distKm;
return a.idx - b.idx;
});
return scored.slice(0, cap).map((entry) => entry.camera);
}
/**
* Normalize a raw CCTV source item into a canonical shape with safe defaults.
*
* @param {object} item - Raw source from file, env, or Austin Open Data.
* @returns {object} Normalized source with all expected fields populated.
*/
export function normalizeSourceItem(item) {
return {
id: String(item.id || '').trim(),
name: String(item.name || item.id || '').trim(),
city: String(item.city || ''),
cityId: String(item.cityId || ''),
provider: String(item.provider || 'Configured CCTV Source'),
lat: toFiniteNumber(item.lat),
lon: toFiniteNumber(item.lon),
headingDeg: toFiniteNumber(item.headingDeg),
headingConfidence: String(
item.headingConfidence || item.headingSource || '',
).toLowerCase(),
pitchDeg: toFiniteNumber(item.pitchDeg),
fovDeg: toFiniteNumber(item.fovDeg),
rangeM: toFiniteNumber(item.rangeM),
mountHeightM: toFiniteNumber(item.mountHeightM),
groundElevationM: toFiniteNumber(item.groundElevationM),
feedType: normalizeFeedType(item.feedType || item.type || ''),
url: typeof item.url === 'string' ? item.url : '',
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
license: String(item.license || item.licenseNote || ''),
// Per-camera attribution for feeds a partner supplies inside a pack
// (DriveBC: TransLink, city cameras). Shown beside the provider.
credit: String(item.credit || '').trim(),
// Unselected-label code: the pack's explicit short name, else the feed's
// name, else the id.
code: cameraDisplayCode(
item.code || String(item.name || '').toUpperCase() || item.id || '',
),
sourceKind: String(item.sourceKind || item.kind || 'configured'),
// Optional CAL badge input (cctv-v2 design §3b/§9.2, additive-only per the
// global constraints — nothing else in this file changes): hand-authored
// file/env catalog entries may declare poseSource:'curated' so the panel
// badge can distinguish them from raw automated priors (e.g. Austin Open
// Data, which never sets this field). Passed through as-is to the client.
poseSource: item.poseSource === 'curated' ? 'curated' : undefined,
};
}
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
/**
* Haversine great-circle distance between two WGS-84 points.
*
* @param {number} lat1 - Latitude of point A (degrees).
* @param {number} lon1 - Longitude of point A (degrees).
* @param {number} lat2 - Latitude of point B (degrees).
* @param {number} lon2 - Longitude of point B (degrees).
* @returns {number} Distance in kilometers.
*/
export function haversineKm(lat1, lon1, lat2, lon2) {
const toRad = (value) => (value * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Read a fetch() Response body as text with a hard byte cap. Rejects early on an
* oversized Content-Length, then streams with a running cap so a chunked or
* length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
*/
export async function readResponseTextCapped(response, maxBytes, signal) {
const tooLarge = () =>
Object.assign(new Error('Upstream response too large'), {
code: 'RESPONSE_TOO_LARGE',
});
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
void response.body?.cancel().catch(() => {});
throw tooLarge();
}
signal?.throwIfAborted();
const reader = response.body?.getReader?.();
if (!reader) {
const text = await response.text();
signal?.throwIfAborted();
if (Buffer.byteLength(text) > maxBytes) throw tooLarge();
return text;
}
const cancel = () => {
void reader.cancel().catch(() => {});
};
signal?.addEventListener('abort', cancel, { once: true });
const decoder = new TextDecoder();
let out = '';
let total = 0;
try {
for (;;) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
signal?.throwIfAborted();
if (done) break;
total += value.byteLength;
if (total > maxBytes) throw tooLarge();
out += decoder.decode(value, { stream: true });
}
return out + decoder.decode();
} catch (error) {
cancel();
throw error;
} finally {
signal?.removeEventListener('abort', cancel);
reader.releaseLock();
}
}
/** Parse a fetch() JSON response only after enforcing a hard byte cap. */
export async function readResponseJsonCapped(response, maxBytes) {
return JSON.parse(await readResponseTextCapped(response, maxBytes));
}
/**
* Return the existing promise for a cache key, or create one and remove it
* only when that exact promise settles.
*/
export function coalesceProxyRequest(inFlight, key, create) {
const existing = inFlight.get(key);
if (existing) return { promise: existing, shared: true };
let promise;
promise = Promise.resolve()
.then(create)
.finally(() => {
if (inFlight.get(key) === promise) inFlight.delete(key);
});
inFlight.set(key, promise);
return { promise, shared: false };
}
/**
* Read a fetch Response body as text while enforcing a hard byte cap during
* the read — so a malicious or buggy upstream that streams an unbounded body
* (no/oversized Content-Length, chunked) can't OOM the proxy. Returns
* { tooLarge, text }. Cancels the stream as soon as the cap is crossed.
* @param {Response} upstream - fetch() response.
* @param {number} maxBytes - hard ceiling on decoded bytes.
* @returns {Promise<{tooLarge: boolean, text: string}>}
*/
export async function readCappedResponseText(upstream, maxBytes) {
const declared = Number(upstream.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
return { tooLarge: true, text: '' };
}
if (
!upstream.body ||
typeof upstream.body[Symbol.asyncIterator] !== 'function'
) {
const text = await upstream.text();
return text.length > maxBytes
? { tooLarge: true, text: '' }
: { tooLarge: false, text };
}
const decoder = new TextDecoder();
let text = '';
let total = 0;
for await (const chunk of upstream.body) {
total += chunk.length;
if (total > maxBytes) {
try {
await upstream.body.cancel();
} catch {
/* no-op */
}
return { tooLarge: true, text: '' };
}
text += decoder.decode(chunk, { stream: true });
}
text += decoder.decode();
return { tooLarge: false, text };
}
+12
View File
@@ -0,0 +1,12 @@
export function requiredFiniteQueryNumber(params, key) {
const value = params.get(key);
if (value === null || value.trim() === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
export function clampInt(value, min, max, fallback) {
const number = Number.parseInt(value, 10);
if (!Number.isFinite(number)) return fallback;
return Math.max(min, Math.min(max, number));
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Minimal fixed-window per-key rate limiter for the dev proxies. Not a hard
* security boundary (dev-only), just a backstop so a runaway client can't hammer
* the public Overpass / OSRM mirrors or exhaust this process.
*/
const RATE_LIMITER_MAX_KEYS = 2000;
export function makeRateLimiter({ windowMs, max, globalMax }) {
const hits = new Map(); // key -> number[] (timestamps within window)
let globalTimes = []; // all hits in window, for the global backstop
return function allow(key) {
const now = Date.now();
globalTimes = globalTimes.filter((t) => now - t < windowMs);
if (globalMax && globalTimes.length >= globalMax) return false; // global backstop
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs);
if (recent.length >= max) {
hits.set(key, recent);
return false;
}
recent.push(now);
hits.set(key, recent);
globalTimes.push(now);
// Hard key cap so a key-rotating caller can't grow the map without bound.
if (hits.size > RATE_LIMITER_MAX_KEYS) {
const oldest = hits.keys().next().value;
if (oldest !== undefined) hits.delete(oldest);
}
if (hits.size > 256) {
for (const [k, v] of hits) {
if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k);
}
}
return true;
};
}
/**
* Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
* DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this
* returns `null` and the caller skips the check entirely — a runtime no-op that
* preserves the original behavior. Only a positive integer N enables a fixed
* 60s window of N requests/IP (built lazily once, then reused so its per-IP
* window state persists across requests). The global backstop is set to a
* generous multiple of the per-IP cap so a single host can't starve the rest.
*
* @param {string|undefined} envValue - Raw env value (requests/min/IP).
* @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited.
*/
export function makeOptInRateLimiter(envValue) {
const max = Number(envValue);
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited
return makeRateLimiter({
windowMs: 60_000,
max: Math.floor(max),
globalMax: Math.floor(max) * 20,
});
}
/**
* Client key for rate limiting. Uses the real socket peer address only — we do
* NOT trust X-Forwarded-For (client-controlled; a rotating value would mint fresh
* quota and grow the limiter map). This is a localhost dev proxy, so the socket
* address is the real client.
*/
export function clientKey(req) {
return String(req.socket?.remoteAddress || 'local');
}
+35
View File
@@ -0,0 +1,35 @@
/** Read a request body with a hard byte cap; throws { code:'BODY_TOO_LARGE' } past the cap. */
async function readRequestBodyCapped(req, maxBytes) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > maxBytes) {
const err = new Error('Request body too large');
err.code = 'BODY_TOO_LARGE';
throw err;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function readRequestBody(req, maxBytes = 1024 * 1024) {
return new Promise((resolve, reject) => {
let total = 0;
const chunks = [];
req.on('data', (chunk) => {
total += chunk.length;
if (total > maxBytes) {
reject(new Error(`Request body exceeds ${maxBytes} bytes`));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
export { readRequestBodyCapped, readRequestBody };
+10
View File
@@ -0,0 +1,10 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Resolve defaultSourceRoot for ESM context. */
const defaultSourceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../..',
);
export { defaultSourceRoot };
+261
View File
@@ -0,0 +1,261 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
/**
* NASA FIRMS live active-fire proxy with a memory + disk cache.
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
*
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
* satellites, no cross-source dedup) fetched sequentially with `days=2`
* (`days=1` means "current UTC day", nearly empty just after 00:00Z) and
* clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS quota is
* 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
* TTL 30 min, single-flight refresh, serve-stale-on-failure, and a
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
*
* Routes:
* GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires}
* GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions}
*
* Keyless (no FIRMS_MAP_KEY): /api/firms → 503 {error:'no_key'}; status →
* {hasKey:false}. Upstream is never touched without a key.
*
* @returns {import('vite').Plugin}
*/
export function firmsProxy() {
const TTL_MS = 30 * 60_000;
const STATUS_TTL_MS = 5 * 60_000;
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
/** @type {?{at: number, sources: Array<object>, fires: Array<object>}} */
let mem = null;
let diskChecked = false;
/** @type {?Promise<?{at: number, sources: Array<object>, fires: Array<object>}>} single-flight refresh */
let inflight = null;
/** @type {?{at: number, transactions: ?{used: number, limit: number}}} mapkey_status cache */
let statusCache = null;
/** @type {?Promise<?{used: number, limit: number}>} */
let statusInflight = null;
const mapKey = () => String(process.env.FIRMS_MAP_KEY || '').trim();
async function readDiskOnce() {
if (diskChecked) return;
diskChecked = true;
try {
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
if (
Number.isFinite(parsed?.at) &&
Array.isArray(parsed?.sources) &&
Array.isArray(parsed?.fires)
) {
mem = parsed;
}
} catch {
/* no disk cache yet */
}
}
async function writeDisk(entry) {
try {
await fsp.mkdir(CACHE_DIR, { recursive: true });
await fsp.writeFile(CACHE_PATH, JSON.stringify(entry), 'utf8');
} catch (err) {
console.warn('[firms-proxy] cache write failed:', err?.message || err);
}
}
/**
* Fetch + parse one FIRMS source. Throws on HTTP error or a non-CSV body
* (FIRMS reports errors as HTML/plain text, never CSV). Never log the URL —
* it embeds the MAP_KEY.
*/
async function fetchSource(key, source) {
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const records = parseFirmsCsv(await res.text());
if (records === null) throw new Error('non-CSV upstream response');
return records;
}
/**
* Refresh all sources sequentially (quota courtesy — never in parallel).
* Partial success (≥1 source ok) still produces a cacheable entry with the
* failed sources marked ok:false; total failure throws so the caller can
* serve stale.
*/
async function refreshUpstream(key) {
const now = Date.now();
const sources = [];
const fires = [];
for (const source of SOURCES) {
try {
const records = filterTrailing24h(await fetchSource(key, source), now);
// NOT fires.push(...records): spread passes each record as an argument,
// and a world/2 VIIRS pull exceeds V8's argument limit (~125k) at
// ~131k records — RangeError, and the whole source is silently dropped.
for (const record of records) fires.push(record);
sources.push({ source, count: records.length, ok: true });
} catch (err) {
console.warn(
`[firms-proxy] ${source} fetch failed:`,
err?.message || err,
);
sources.push({ source, count: 0, ok: false });
}
}
if (!sources.some((s) => s.ok)) throw new Error('all FIRMS sources failed');
return { at: now, sources, fires };
}
/**
* Cache entry → response payload. Fires are RE-filtered to the trailing
* 24 h at serve time so a stale cache never serves >24h-old detections.
*/
function buildPayload(entry, stale) {
const fires = filterTrailing24h(entry.fires, Date.now());
return {
fetchedAt: entry.at,
stale,
ttlMs: TTL_MS,
sources: entry.sources,
count: fires.length,
fires,
};
}
/** mapkey_status transactions, cached 5 min, best-effort (null on failure). */
function getTransactions(key) {
const now = Date.now();
if (statusCache && now - statusCache.at < STATUS_TTL_MS) {
return Promise.resolve(statusCache.transactions);
}
if (!statusInflight) {
statusInflight = (async () => {
try {
const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const used = Number(body?.current_transactions);
const limit = Number(body?.transaction_limit);
return Number.isFinite(used) && Number.isFinite(limit)
? { used, limit }
: null;
} catch (err) {
console.warn(
'[firms-proxy] mapkey status failed:',
err?.message || err,
);
return null;
}
})()
.then((transactions) => {
statusCache = { at: Date.now(), transactions };
return transactions;
})
.finally(() => {
statusInflight = null;
});
}
return statusInflight;
}
const installMiddleware = (server) => {
server.middlewares.use('/api/firms', async (req, res) => {
const sendJson = (status, obj) => {
if (res.headersSent) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(obj));
};
try {
const subPath = String(req.url || '').split('?')[0];
const key = mapKey();
await readDiskOnce();
if (subPath === '/status') {
if (!key) {
sendJson(200, {
hasKey: false,
lastFetch: null,
count: null,
stale: false,
ttlMs: TTL_MS,
transactions: null,
});
return;
}
const transactions = await getTransactions(key);
sendJson(200, {
hasKey: true,
lastFetch: mem ? mem.at : null,
count: mem ? mem.fires.length : null,
stale: mem ? Date.now() - mem.at >= TTL_MS : false,
ttlMs: TTL_MS,
transactions,
});
return;
}
if (!key) {
sendJson(503, { error: 'no_key' });
return;
}
const entry = mem;
if (entry && Date.now() - entry.at < TTL_MS) {
sendJson(200, buildPayload(entry, false));
return;
}
// Stale or missing → refresh, single-flight (concurrent requests
// share one upstream pass). Capture the promise locally BEFORE
// awaiting: the .finally() nulls `inflight` the moment it settles.
if (!inflight) {
inflight = refreshUpstream(key)
.then(async (fresh) => {
mem = fresh;
await writeDisk(fresh);
return fresh;
})
.catch((err) => {
console.warn(
`[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`,
);
return null;
})
.finally(() => {
inflight = null;
});
}
const pending = inflight;
const fresh = await pending;
if (fresh) {
sendJson(200, buildPayload(fresh, false));
} else if (entry) {
sendJson(200, buildPayload(entry, true)); // upstream down — stale beats empty
} else {
sendJson(502, {
error: 'firms fetch failed and no cache available',
});
}
} catch (err) {
console.warn('[firms-proxy] error:', err?.message || err);
sendJson(500, { error: 'firms proxy error' });
}
});
};
return {
name: 'firms-proxy',
configureServer: installMiddleware,
configurePreviewServer: installMiddleware,
};
}
+249
View File
@@ -0,0 +1,249 @@
import { readResponseTextCapped } from './common/http.js';
import {
isAllowedGbfsHost,
isAllowedGbfsPath,
gbfsCacheControl,
} from '../../src/data/gbfsSource.js';
// ---------------------------------------------------------------------------
// GBFS (General Bikeshare Feed Specification) proxy constants
// ---------------------------------------------------------------------------
/** Upstream fetch timeout for GBFS requests (ms). */
const GBFS_PROXY_TIMEOUT_MS = 12000;
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
function gbfsRedirectHost(location, requestUrl) {
if (!location) return '';
try {
return new URL(String(location), requestUrl).hostname;
} catch {
return '';
}
}
/**
* Fetch one validated GBFS endpoint without following redirects and with the
* body cap enforced while the response streams.
*
* The middleware validates the target before calling this; the network step
* lives apart from it so a unit test can stand in for a hostile upstream — the
* host allowlist keeps the real handler from being pointed at a local server.
* `redirect: 'manual'` makes fetch() return a 3xx response instead of
* following it, so an allowlisted operator that redirects cannot steer the
* proxy to a destination the allowlist never saw; any 3xx is rejected with
* { code:'GBFS_REDIRECT', redirectHost }. An oversized body surfaces as
* { code:'RESPONSE_TOO_LARGE' } from readResponseTextCapped, which cancels the
* upstream read the moment the running byte count passes the cap.
*
* @param {string} url - Validated https GBFS endpoint URL.
* @param {object} [options]
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
* @param {number} [options.timeoutMs=GBFS_PROXY_TIMEOUT_MS] - Upstream abort timeout.
* @param {number} [options.maxBytes=GBFS_MAX_BODY_BYTES] - Body byte cap.
* @returns {Promise<{status:number,contentType:string,body:string}>}
*/
export async function fetchGbfsUpstream(
url,
{
fetchImpl = fetch,
timeoutMs = GBFS_PROXY_TIMEOUT_MS,
maxBytes = GBFS_MAX_BODY_BYTES,
} = {},
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
let upstream;
try {
upstream = await fetchImpl(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
},
redirect: 'manual',
signal: controller.signal,
});
if (upstream.status >= 300 && upstream.status < 400) {
try {
void upstream.body?.cancel().catch(() => {});
} catch {
/* no-op */
}
const err = new Error(
'GBFS upstream redirected; redirects are not followed',
);
err.code = 'GBFS_REDIRECT';
err.redirectHost = gbfsRedirectHost(
upstream.headers.get('location'),
url,
);
throw err;
}
const body = await readResponseTextCapped(
upstream,
maxBytes,
controller.signal,
);
return {
status: upstream.status,
contentType: upstream.headers.get('content-type') || 'application/json',
body,
};
} catch (error) {
controller.abort();
if (upstream?.body && !upstream.body.locked) {
void upstream.body.cancel().catch(() => {});
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Vite plugin: GBFS bike-share proxy with host allowlisting and size limits.
*
* Accepts GET /api/gbfs/<encoded-upstream-URL> and proxies the request
* to the upstream GBFS provider. Validates hostname against an allowlist,
* restricts to station_information/station_status paths, enforces HTTPS,
* and caps response body at 5 MB.
*
* @returns {import('vite').Plugin}
*/
export function gbfsProxy() {
const installMiddleware = (server) => {
server.middlewares.use('/api/gbfs', async (req, res) => {
try {
if (req.method !== 'GET') {
res.writeHead(405, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
const url = new URL(req.url || '/', 'http://localhost');
const encodedTarget = url.pathname.replace(/^\/+/, '');
if (!encodedTarget) {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Missing GBFS upstream target' }));
return;
}
let decodedTarget = '';
try {
decodedTarget = decodeURIComponent(encodedTarget);
} catch {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' }));
return;
}
let upstreamUrl = null;
try {
upstreamUrl = new URL(decodedTarget);
} catch {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' }));
return;
}
if (upstreamUrl.protocol !== 'https:') {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({ error: 'Only https GBFS targets are allowed' }),
);
return;
}
if (!isAllowedGbfsHost(upstreamUrl.hostname)) {
res.writeHead(403, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'GBFS host not allowed' }));
return;
}
if (!isAllowedGbfsPath(upstreamUrl.pathname)) {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error:
'Only station_information/station_status endpoints are allowed',
}),
);
return;
}
const upstream = await fetchGbfsUpstream(upstreamUrl.toString());
res.writeHead(upstream.status, {
'Content-Type': upstream.contentType,
'Cache-Control': gbfsCacheControl(upstreamUrl.pathname),
'X-GBFS-Upstream': upstreamUrl.hostname,
'X-GBFS-Cache': 'MISS',
});
res.end(upstream.body);
} catch (error) {
if (error?.name === 'AbortError') {
res.writeHead(504, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'GBFS upstream timeout' }));
return;
}
if (
error?.code === 'GBFS_REDIRECT' ||
error?.code === 'RESPONSE_TOO_LARGE'
) {
if (error.code === 'GBFS_REDIRECT')
console.warn('[GBFS Proxy] Redirect refused', error.redirectHost);
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error:
error.code === 'GBFS_REDIRECT'
? 'GBFS upstream redirect refused'
: 'GBFS upstream response too large',
}),
);
return;
}
console.error('[GBFS Proxy]', error?.message || String(error));
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'GBFS proxy error' }));
}
});
};
return {
name: 'gbfs-proxy',
configureServer: installMiddleware,
configurePreviewServer: installMiddleware,
};
}
Loaded 100 of 692 files, more files were not shown because too many files have changed in this diff. Show more