mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into main
This commit is contained in:
692 files changed
+198677
-69710
No files matched your search
+36
-3
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
+371
@@ -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
@@ -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
@@ -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.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# 🌐 God's Eye View
|
||||
|
||||
[](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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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)."
|
||||
}
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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>🔎</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>
|
||||
|
||||
Generated
+372
-1279
File diff suppressed because it is too large.
Load diff
+116
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
@@ -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 || ""}}',
|
||||
|
||||
@@ -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 || ""}}',
|
||||
|
||||
@@ -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 || ""}}',
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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') });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; } });
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+923
-24
File diff suppressed because it is too large.
Load diff
+262
-141
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']] },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user