mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
195
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43db5c19bb | ||
|
|
0431dbb05e | ||
|
|
b1e67a6a50 | ||
|
|
350a1afd4f | ||
|
|
3e078ef49d | ||
|
|
fc369cc65a | ||
|
|
7f70b97536 | ||
|
|
1b9ea0e8e3 | ||
|
|
e1c3045203 | ||
|
|
c6ac74c242 | ||
|
|
4298fb0442 | ||
|
|
1a7e4d51d9 | ||
|
|
f45f399c31 | ||
|
|
bd14d6c301 | ||
|
|
a9e7cb6701 | ||
|
|
457fe5451c | ||
|
|
a5af1cbfa3 | ||
|
|
b119aef0ad | ||
|
|
11aa90c7bb | ||
|
|
53c510fe9f | ||
|
|
641a2c67e2 | ||
|
|
02e90ca698 | ||
|
|
bd6cc5abe8 | ||
|
|
7b9aca8597 | ||
|
|
79a0f09087 | ||
|
|
4e33e98377 | ||
|
|
9bbb437592 | ||
|
|
220caf5427 | ||
|
|
63e5352964 | ||
|
|
7485565975 | ||
|
|
82d922ae87 | ||
|
|
ea77b49482 | ||
|
|
20c1611354 | ||
|
|
9f6cde79fd | ||
|
|
dec9dd288c | ||
|
|
a41f3ec3cd | ||
|
|
4d764a82db | ||
|
|
648041e040 | ||
|
|
4a3f53bfcd | ||
|
|
e34d619949 | ||
|
|
07cf1695ff | ||
|
|
31f1923633 | ||
|
|
8b21bd8ac5 | ||
|
|
da7496b7eb | ||
|
|
72d531b18f | ||
|
|
78199c2ab3 | ||
|
|
12f7e78d83 | ||
|
|
1bbb6d9c70 | ||
|
|
c30f7cc3be | ||
|
|
5f60d86bc9 | ||
|
|
b99669d689 | ||
|
|
ffc65dd844 | ||
|
|
795ed68192 | ||
|
|
2820b2ab8c | ||
|
|
d7623a148f | ||
|
|
2001df04e2 | ||
|
|
c9be877f9c | ||
|
|
7a5f615d55 | ||
|
|
5fa8db1686 | ||
|
|
5ca085e709 | ||
|
|
e13a680b53 | ||
|
|
bf77ae61b8 | ||
|
|
83df873879 | ||
|
|
bd6ba07878 | ||
|
|
e1ffb9c572 | ||
|
|
d5b09efa74 | ||
|
|
f35468040e | ||
|
|
29653cb3d8 | ||
|
|
b892893308 | ||
|
|
d0aa6a51a5 | ||
|
|
dae5f45426 | ||
|
|
33ad0de4c6 | ||
|
|
dcd20dde10 | ||
|
|
8eb6d09105 | ||
|
|
dfb5f2cfef | ||
|
|
24d2582516 | ||
|
|
3d956177ab | ||
|
|
59a20e2d18 | ||
|
|
c78ea4194c | ||
|
|
3b19af4076 | ||
|
|
8bcf1460fc | ||
|
|
48a2f06fa2 | ||
|
|
3263fe6ff1 | ||
|
|
eec4dd5cdb | ||
|
|
57d7f9fc23 | ||
|
|
1a9cfb1809 | ||
|
|
1194afe468 | ||
|
|
4fae7fb278 | ||
|
|
db49edf363 | ||
|
|
4e99f40488 | ||
|
|
3f642f5079 | ||
|
|
5223b6df5b | ||
|
|
233285fe12 | ||
|
|
a64a622b6d | ||
|
|
cd92855f88 | ||
|
|
638902296a | ||
|
|
318d37ad8d | ||
|
|
213edd80ce | ||
|
|
781613fbb8 | ||
|
|
280fc00a51 | ||
|
|
a0f07b752f | ||
|
|
276bb84904 | ||
|
|
c69416ff6b | ||
|
|
79b4929b88 | ||
|
|
fada0c661f | ||
|
|
c6462be066 | ||
|
|
f9b939eeef | ||
|
|
aacfa06a31 | ||
|
|
eadeac6c10 | ||
|
|
a2d6461b52 | ||
|
|
f4054469d5 | ||
|
|
3ec3fe6822 | ||
|
|
290afd808d | ||
|
|
9a4b98700e | ||
|
|
bbabba89bf | ||
|
|
38b591863c | ||
|
|
f883dfbc1e | ||
|
|
7e6d89d8f5 | ||
|
|
2793d7a8df | ||
|
|
bdfd02ba93 | ||
|
|
bac45815d1 | ||
|
|
2e3cb6270a | ||
|
|
f9a85528fe | ||
|
|
0e221441b4 | ||
|
|
149517da81 | ||
|
|
69bb834bac | ||
|
|
350896a900 | ||
|
|
fb67024f43 | ||
|
|
8de1018c21 | ||
|
|
493acfc6aa | ||
|
|
8666705e12 | ||
|
|
4d37760314 | ||
|
|
092f28e301 | ||
|
|
f70b5a3631 | ||
|
|
04f4d54bfe | ||
|
|
76ddc6a9ef | ||
|
|
ef69f01cd8 | ||
|
|
205c58efbd | ||
|
|
e9b6214ec4 | ||
|
|
a8eae34787 | ||
|
|
67504cd871 | ||
|
|
01dcecf6e9 | ||
|
|
162ac4f2c9 | ||
|
|
ed15c5b38e | ||
|
|
106a794afa | ||
|
|
4688eca9af | ||
|
|
142bf583f8 | ||
|
|
e465197cee | ||
|
|
a547e99a7b | ||
|
|
412ee211b5 | ||
|
|
b5568de9fa | ||
|
|
6dd00d1e76 | ||
|
|
3f77c690f3 | ||
|
|
c7ef01827d | ||
|
|
1a74e56bc0 | ||
|
|
77575b9dc6 | ||
|
|
5228ab9e40 | ||
|
|
c82fdd7478 | ||
|
|
5a8738c11d | ||
|
|
e9fd272ade | ||
|
|
f94bdb1d77 | ||
|
|
f47cd792d1 | ||
|
|
8fc86e0780 | ||
|
|
a458d27067 | ||
|
|
231d339e92 | ||
|
|
60812df34c | ||
|
|
5dfef8e891 | ||
|
|
6fb8cc37d5 | ||
|
|
3d526f208d | ||
|
|
e7cb08f5f6 | ||
|
|
87a92c8bc4 | ||
|
|
024738a8d2 | ||
|
|
a4b6dbb41f | ||
|
|
5067abfe9b | ||
|
|
ac3926c7db | ||
|
|
0584a1d71d | ||
|
|
ea344c952f | ||
|
|
6c49c2aa22 | ||
|
|
56d52e284f | ||
|
|
db7ff73bbf | ||
|
|
fd01f22b88 | ||
|
|
9e92ab6f9b | ||
|
|
c390724bc0 | ||
|
|
759652207f | ||
|
|
e21c174f76 | ||
|
|
acac1e4842 | ||
|
|
f16fc2db0d | ||
|
|
3b6b3f1007 | ||
|
|
b6d0518960 | ||
|
|
db35b4978b | ||
|
|
25838e056a | ||
|
|
7ffa421932 | ||
|
|
973b4e147f | ||
|
|
9dfb5369e8 | ||
|
|
10df9c01e2 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* @bilawalsidhu @samehkhamis
|
||||
@@ -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"
|
||||
}
|
||||
+261
@@ -1,10 +1,271 @@
|
||||
# Changelog
|
||||
|
||||
## 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 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.
|
||||
|
||||
### 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.
|
||||
- Mapped installations retain valid ways and relations that provide bounds but
|
||||
no center. Invalid, inverted, and excessively wide bounds are rejected.
|
||||
- Clicking a selected installation again or clicking elsewhere clears its
|
||||
selection; later refreshes no longer reclaim it after a click-away.
|
||||
- Visual presets explain their effects on hover. Unavailable map sources name
|
||||
missing credentials and Provider Settings, while configured-but-failed
|
||||
Google 3D routes explain the failure without asking for another key.
|
||||
|
||||
- The Overpass proxy now rotates to the next mirror on any non-2xx upstream
|
||||
response, not only on 5xx. `overpass-api.de` and its `lz4` alias answer 406 to
|
||||
the proxy's User-Agent while two of the configured mirrors answer 200 to the
|
||||
identical request, so the fan-out stopped at the first refusal with healthy
|
||||
mirrors untried. The refusal was also cached to memory and disk and served as
|
||||
data — boundary-class queries hold a month-long TTL — which affected every
|
||||
Overpass-backed feature: road geometry, annotation outlines and place lookup.
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
|
||||
+35
-2
@@ -27,13 +27,23 @@ through Pinokio; the terminal path above remains the contributor path.
|
||||
|
||||
Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.**
|
||||
|
||||
## 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
|
||||
|
||||
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).
|
||||
|
||||
@@ -41,7 +51,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
|
||||
@@ -51,6 +61,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`.
|
||||
@@ -59,6 +85,13 @@ The highest-leverage places to jump in:
|
||||
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
||||
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
|
||||
|
||||
## Maintainers
|
||||
|
||||
God's Eye View is maintained by [Bilawal Sidhu](https://github.com/bilawalsidhu)
|
||||
and [Sameh Khamis](https://github.com/samehkhamis) at
|
||||
[Halfpixel](https://halfpixel.ai). Either maintainer can review and merge
|
||||
contributions.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- This is a tool for **public** data. Don't add scraping of sources whose terms forbid it, private/paywalled datasets, or anything that misrepresents public-data inference as authoritative intelligence.
|
||||
|
||||
@@ -27,6 +27,7 @@ How to read this:
|
||||
| **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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -16,11 +18,15 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
|
||||
|
||||
▶️ **From the project behind the viral God's Eye View series** *(formerly WorldView)* — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view)
|
||||
|
||||
[](https://github.com/trending)
|
||||
[](https://x.com/bilawalsidhu/status/2093798887815348521)
|
||||
|
||||
🏆 **#1 on GitHub Trending this past week — thank you.** You asked for a one-click install; it's here.
|
||||
🏆 **Reached #1 on GitHub Trending, daily and weekly · August 2026**
|
||||
|
||||
⚡ **No keys, no signup, no config file.** One click through [Pinokio](https://pinokio.computer/) — or `npm install && npm run dev` — and the globe comes to life. Keys are power-ups you paste into the app later. **[→ Quick Start](#-quick-start)**
|
||||
**[#8 Product of the Day](https://www.producthunt.com/products/god-s-eye-view?launch=god-s-eye-view)** · Hunted by Chris Messina, creator of the hashtag
|
||||
|
||||
*“pretty cool”* — [Brendan Eich](https://x.com/BrendanEich/status/2094592096401490266), creator of JavaScript and co-founder of Mozilla and Brave · Featured on **[Pinokio](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g)**
|
||||
|
||||
⚡ **Start without API keys.** Install with [Pinokio](https://pinokio.co/apps/github-com-bilawalsidhu-gods-eye-view) or run locally from the terminal. Add optional keys inside the app. **[→ Quick Start](#-quick-start)**
|
||||
|
||||
</div>
|
||||
|
||||
@@ -36,9 +42,9 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
|
||||
|
||||
## 🌍 Why This Exists
|
||||
|
||||
**You asked, so it's happening.** God's Eye View is open source. Track the world live. Talk to it. Break it. Extend it.
|
||||
God's Eye View brings public signals into one explorable globe. Track the world live. Talk to it. Break it. Extend it.
|
||||
|
||||
Most open-source intelligence is a pile of browser tabs. The signals are abundant, but the *interface* is the bottleneck. God's Eye View turns those signals into a **place**: the world is already broadcasting — flight transponders, ship beacons, orbital elements, seismographs, public cameras — and this makes it visible on a photorealistic 3D Earth in real time. No classified clearance required; it's public signal all the way down, and the interface runs in your browser, under your control.
|
||||
Flight transponders, ship beacons, orbital elements, seismographs, and public cameras already tell us a lot about the world. God's Eye View puts them in the same place, so you can move between a global picture and an individual aircraft, ship, or street. It runs locally in your browser, with source code you can inspect and extend.
|
||||
|
||||
> Half the magic is that it looks like a forbidden cockpit. The other half is that every line of code is inspectable.
|
||||
|
||||
@@ -46,9 +52,7 @@ Most feeds are live or regularly refreshed. Traffic is simulated along real
|
||||
roads using aggregate location data. CCTV camera poses and rocket launch
|
||||
trajectories are coarse estimates.
|
||||
|
||||
You'll be surprised how accessible this is. Free and nearly-free APIs deliver
|
||||
a surprisingly complete experience out of the box — then it's yours to extend
|
||||
with bigger data sources whenever you're ready.
|
||||
Start with the included data sources, then add your own. Each layer is a separate module.
|
||||
|
||||
---
|
||||
|
||||
@@ -79,48 +83,54 @@ with bigger data sources whenever you're ready.
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**Nothing to sign up for to get started.** Both paths below land you in the
|
||||
same place: a live satellite globe — keyless Esri World Imagery with keyless
|
||||
terrain, and OSM stepping in automatically if Esri is ever unreachable — with
|
||||
aircraft, military traffic, satellites, earthquakes, public cameras, radio and
|
||||
launches already moving on it. No account, no key, no file to edit.
|
||||
**Start without an account or API keys.** Both paths open the same app with
|
||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||
cameras, radio, and launches are available without keys.
|
||||
|
||||
**Optional signups, optimal experience.** The keyless globe gets you running;
|
||||
a couple of two-minute signups make it spectacular. Want the photorealistic-3D
|
||||
cities? A **free Cesium ion token** covers them for eligible personal,
|
||||
non-commercial use — no Google account needed; current ion terms and quotas
|
||||
apply. Prefer them straight from Google, plus in-app place search? A
|
||||
**Google Maps key** is the billing-enabled, metered route — with a surprisingly
|
||||
generous free tier ([real numbers](#-api-keys)). Either one pastes straight
|
||||
into **Then power it up** below.
|
||||
For photorealistic 3D, add a **Cesium ion token** for eligible personal,
|
||||
non-commercial use, or a **Google Maps key** for the direct, metered route and
|
||||
in-app place search. Provider terms and quotas apply. Add keys through the
|
||||
app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options.
|
||||
|
||||
### Path 1 — One click, no terminal
|
||||
|
||||
1. Install [Pinokio](https://pinokio.computer/).
|
||||
2. In **Discover → Download from URL**, paste
|
||||
`https://github.com/bilawalsidhu/gods-eye-view`.
|
||||
1. Install or update [Pinokio](https://desktop.pinokio.co/) to **8.2 or later**.
|
||||
2. Open [God's Eye View in Pinokio](https://pinokio.co/apps/github-com-bilawalsidhu-gods-eye-view).
|
||||
3. Click **Install**, then **Start**.
|
||||
|
||||
That is the whole thing. The launcher verifies Pinokio's runtime, installs the
|
||||
Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports
|
||||
cross-platform testing of the fixed installer. The launcher installs the
|
||||
locked dependencies, finds a free local port, and opens the app.
|
||||
|
||||
**Tried before and installation failed?** Update Pinokio and try again.
|
||||
Version 8.2 fixes the launcher installation issue;
|
||||
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
|
||||
|
||||
### Path 2 — Terminal / coding agent
|
||||
|
||||
Requires Node.js 24.14.x or 26.x. Node 25 is usable but EOL; the setup doctor
|
||||
warns instead of blocking it.
|
||||
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
||||
Node 25, which is end-of-life.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
git clone https://github.com/bilawalsidhu/gods-eye-view.git
|
||||
cd gods-eye-view
|
||||
npm ci
|
||||
npm run doctor
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **`http://localhost:4173`**. Cold start settles in under two seconds on a
|
||||
recent laptop (median 1.86 s in a point-in-time M5/Chrome capture —
|
||||
[docs/PERFORMANCE.md](docs/PERFORMANCE.md); a comparison baseline, not a hardware
|
||||
requirement). A first-run card offers to stage a mission for you — **Live
|
||||
Contacts**, **Space Missions**, **Environmental** — or leaves you to explore
|
||||
manually.
|
||||
Open **`http://localhost:4173`**. Choose **Live Contacts**, **Space Missions**,
|
||||
**Environmental**, or **Explore Manually** from the first-run panel.
|
||||
|
||||
<details>
|
||||
<summary>Startup performance</summary>
|
||||
|
||||
A point-in-time M5/Chrome capture measured a median 1.86-second cold start.
|
||||
This is a comparison baseline, not a guarantee for your machine or connection.
|
||||
See [docs/PERFORMANCE.md](docs/PERFORMANCE.md).
|
||||
|
||||
</details>
|
||||
|
||||
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
|
||||
configured keys straight from the Keychain. It starts keyless too.
|
||||
@@ -136,7 +146,8 @@ reopens the same panel.
|
||||
|
||||
- **Where keys land:** Pinokio → the app's ignored `pinokio/ENVIRONMENT`; a
|
||||
terminal clone → the repo-root `.env`. Either file is made owner-only
|
||||
*before* a secret is written into it, and it never leaves your machine.
|
||||
*before* a secret is written into it. These are local plaintext files,
|
||||
excluded from Git; the app uses your keys to contact the providers.
|
||||
- **Keys you already have stay yours:** values from your shell or the macOS
|
||||
Keychain show as *configured externally* and are read-only to the panel.
|
||||
- **What to get first:** the free [Cesium ion](https://cesium.com/ion) token
|
||||
@@ -145,12 +156,17 @@ reopens the same panel.
|
||||
billing-enabled, metered route + place search; OpenAI when you want to talk
|
||||
to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
|
||||
|
||||
> [!WARNING]
|
||||
> Do not enter credentials in Pinokio 8.0.40's native **Configure** panel: that
|
||||
> release does not save this nested app file correctly, and it logs submitted
|
||||
> values. Use Provider Settings inside the app instead. Both file stores are
|
||||
> local plaintext; on macOS the Keychain via `./scripts/dev-fresh.sh` remains
|
||||
> the stronger option.
|
||||
<details>
|
||||
<summary>Older Pinokio versions and credential storage</summary>
|
||||
|
||||
Do not enter credentials in Pinokio 8.0.40's native **Configure** panel: that
|
||||
release does not save this nested app file correctly, and it logs submitted
|
||||
values. Use **POWER UP → Provider Settings** inside GEV instead. The Pinokio
|
||||
8.2 announcement fixes installation; it does not establish that this separate
|
||||
Configure issue is resolved. On macOS, the Keychain via
|
||||
`./scripts/dev-fresh.sh` remains the stronger storage option.
|
||||
|
||||
</details>
|
||||
|
||||
The server binds to **localhost** on both paths, and Provider Settings answers
|
||||
requests only from your machine. Browser-side keys (Google Maps, Cesium ion)
|
||||
@@ -161,7 +177,7 @@ and it carries the LAN-sharing rules alongside [Keys & Costs](#-api-keys).
|
||||
|
||||
## 🕐 The First Five Minutes
|
||||
|
||||
No account, no signup. The first-run card will offer to stage a mission for you — or run this gauntlet yourself. Somewhere in these five minutes it stops feeling like a demo:
|
||||
Choose a first-run mission, or try these in order. The GIFs show Google Photorealistic 3D; your starting basemap depends on the keys you've added.
|
||||
|
||||
1. **Light up the sky.** Take the **Live Contacts** mission (or turn on **Flights** yourself) — thousands of live aircraft, gliding on real telemetry, detection mesh already reading the scene. Click one: the camera locks on, a trail draws behind it, and its live telemetry card comes up.
|
||||
2. **Take the controls.** Hit **COCKPIT** on your tracked plane and ride it down, switching sensors mid-flight: NVG into Ironbow FLIR.
|
||||
@@ -248,7 +264,7 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod
|
||||
|
||||
## 🛰️ What's on the Globe
|
||||
|
||||
Thirteen live layers. **Eleven of them need nothing at all** — no key, no account, no signup, starting with the satellite basemap you land on. (🟢 nothing · 🟡 free key · 🔴 metered.)
|
||||
Thirteen layers and map sources. **Eleven have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
|
||||
| Layer | What you get | Source | Auth |
|
||||
|-------|--------------|--------|------|
|
||||
@@ -258,7 +274,7 @@ Thirteen live layers. **Eleven of them need nothing at all** — no key, no acco
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **Traffic** | Live congestion driving per-vehicle flow at street level — dive below ~8 km and the dots color to real jams. Keyless it's an approximate simulation | TomTom + OSM | 🟢 (🟡 TomTom makes it real — get one) |
|
||||
| 🚗 **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 | 🟢 |
|
||||
| 📻 **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 | 🟢 |
|
||||
@@ -322,14 +338,14 @@ Once the basics click, run these:
|
||||
|
||||
## 🔧 Under the Hood
|
||||
|
||||
Some of the engineering that makes it feel real rather than like a tech demo:
|
||||
How the globe handles live data:
|
||||
|
||||
- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
|
||||
- **Smooth motion from choppy data.** Live feeds arrive every 15–30s; the globe renders one interval behind real time and interpolates between known fixes. Dead reckoning fills the gaps.
|
||||
- **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker.
|
||||
- **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating.
|
||||
- **Spends your quota like it's its own.** The paid feeds run behind cached, budget-governed proxies — an OpenSky credit governor, a TomTom daily tile budget, disk-cached TLEs — so an afternoon of exploring doesn't torch an API allowance.
|
||||
- **Secure by design.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
|
||||
- **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls.
|
||||
- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
|
||||
- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
|
||||
|
||||
```
|
||||
@@ -339,9 +355,9 @@ src/
|
||||
├── hud.js # Intelligence HUD + AI scene summary
|
||||
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
|
||||
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
|
||||
├── iconOrientation.js # Screen-projected world-space headings + horizon cull
|
||||
├── voice/ # OpenAI Realtime session + 28 voice tools
|
||||
├── data/ # One module per layer + orchestration + context store
|
||||
│ ├── iconOrientation.js # Screen-projected headings + horizon cull
|
||||
│ └── local_data/ # Bundled datasets (per-folder provenance)
|
||||
└── scenes/ # Cinematic scene director
|
||||
```
|
||||
@@ -352,17 +368,14 @@ See [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md) for the authoritative runti
|
||||
|
||||
## 🔑 API Keys
|
||||
|
||||
**The legend, one more time:** 🟢 **no signup** — works out of the box · 🟡 **free key** — register, paste, done · 🔴 **metered** — a billing-enabled account; costs are small but real.
|
||||
🟢 **No key** · 🟡 **Free key** · 🔴 **Metered**
|
||||
|
||||
Most of the globe is 🟢: flights (anonymous), military traffic, satellites, earthquakes, CCTV, radio, bikeshare, space missions, mapped installations, and every bundled dataset run with **zero keys**.
|
||||
Use **POWER UP → Provider Settings** to add keys. The tables below explain what
|
||||
each provider enables; none is required to start. See the
|
||||
[setup instructions](#then-power-it-up--in-the-app-not-in-a-file) for storage
|
||||
and configuration details.
|
||||
|
||||
**And you never have to edit a file to add one.** Click **POWER UP** in the
|
||||
bottom-right corner of the running app, paste the key into Provider Settings,
|
||||
hit **SAVE KEYS** — the app writes it to its own local store with owner-only
|
||||
permissions and restarts itself. Everything below is the map of what each key
|
||||
actually buys you.
|
||||
|
||||
### What you need for the good experience
|
||||
### Choose the capabilities you want
|
||||
|
||||
Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
||||
|
||||
@@ -371,9 +384,9 @@ Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
||||
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
|
||||
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
||||
| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
|
||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free, seriously, it's a two-minute signup |
|
||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||
| 🟡 | **TomTom** | 🚦 Real traffic instead of an approximate simulation | [developer.tomtom.com](https://developer.tomtom.com) — free tier is plenty, completely worth it |
|
||||
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
|
||||
|
||||

|
||||
|
||||
@@ -386,7 +399,7 @@ Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
|
||||
All of them are worth getting. None of them are required to start.
|
||||
Add these if you need higher polling allowances.
|
||||
|
||||
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||
where each configured provider was found without printing credential values.
|
||||
@@ -396,7 +409,10 @@ OpenSky summary reports only OAuth client-pair presence, not the resolved
|
||||
runtime mode or credential validity; Basic and credentials-file modes remain
|
||||
advanced `dev-fresh.sh` configuration.
|
||||
|
||||
**If you'd rather not use the panel** — headless boxes, coding agents, scripted setups:
|
||||
<details>
|
||||
<summary>Advanced setup: environment variables and macOS Keychain</summary>
|
||||
|
||||
For headless machines, coding agents, or scripted setups:
|
||||
|
||||
```bash
|
||||
# Put keys in .env (see .env.example), or pass them as env vars:
|
||||
@@ -412,6 +428,8 @@ security add-generic-password -U -s "cesium-ion" -a "token" -w
|
||||
|
||||
OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth credentials with `./scripts/opensky-import-client.sh /path/to/credentials.json`.
|
||||
|
||||
</details>
|
||||
|
||||
### 💸 What it actually costs
|
||||
|
||||
Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages:
|
||||
@@ -437,22 +455,14 @@ Everything above is the deliberately cheap baseline — enough to get a real tas
|
||||
|
||||
### 🔒 Sharing an instance
|
||||
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **set provider-side budget caps** (Google Cloud budgets, OpenAI usage limits): the throttles are app-level guards, not billing caps. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
|
||||
Provider Settings switches itself off whenever the server is shared. The panel
|
||||
answers loopback requests only, and any sharing mode disables the surface
|
||||
outright rather than trusting the socket — tunnelled traffic reaches the server
|
||||
from loopback too, so socket identity can't carry that boundary. Nobody on your
|
||||
LAN gets a key-entry form.
|
||||
Provider Settings is disabled when the server is shared, so remote users cannot
|
||||
access the key-entry panel.
|
||||
|
||||
Pinokio LAN and Cloudflare sharing are currently unavailable for this launcher.
|
||||
The supported Pinokio release can activate sharing again when the Open-action
|
||||
URL is registered, and writes a successful tunnel-login passcode into its own
|
||||
notification and terminal stream. Before preflight, the launcher rewrites both
|
||||
sharing modes to disabled values, clears the child passcode, and pins Pinokio's
|
||||
share trigger to a disabled sentinel. The app then starts loopback-only and
|
||||
registers the standard Open URL. Use a separate reviewed authentication proxy
|
||||
if remote access is required.
|
||||
**Pinokio LAN and Cloudflare sharing remain disabled for this launcher.** Use
|
||||
a separately reviewed authentication proxy if remote access is required.
|
||||
[SECURITY.md](SECURITY.md) explains the restrictions and threat model.
|
||||
|
||||
---
|
||||
|
||||
@@ -466,6 +476,8 @@ God's Eye View runs on **public data, clear sources, and local-first execution.*
|
||||
|
||||
**Status:** An evolving open-source client for exploration and learning — a fast, hackable foundation, not a hardened production service. Released under the **[MIT License](LICENSE)**. Bundled and live datasets carry their own terms — see **[DATA_SOURCES.md](DATA_SOURCES.md)**. Security model: **[SECURITY.md](SECURITY.md)**. Want to contribute? **[CONTRIBUTING.md](CONTRIBUTING.md)**.
|
||||
|
||||
**Maintainers:** [Bilawal Sidhu](https://github.com/bilawalsidhu) and [Sameh Khamis](https://github.com/samehkhamis) at [Halfpixel](https://halfpixel.ai).
|
||||
|
||||
<sub>Media note: the capture GIFs on this page show Google Photorealistic 3D Tiles and live data layers, used promotionally with in-frame attribution; they aren't licensed for standalone reuse. See [media provenance and permissions](docs/media/README.md); full source terms in [DATA_SOURCES.md](DATA_SOURCES.md).</sub>
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -485,7 +497,9 @@ First — thank you. To everyone who watched the God-view demos and went off to
|
||||
|
||||
So here it is. Step inside the spy-thriller cockpit — except the data is real — and let's turn this into our shared sandbox for making sense of the world, and have fun doing it. This repo is the baseline, it stays open, and the whole point is for you to break things and bolt on layers we haven't thought of yet.
|
||||
|
||||
One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing *what happened* and *what changed* at any real resolution — the data gets expensive and the compute gets brutal. For that, we're building something cool. More in the future — [halfpixel.ai](https://halfpixel.ai).
|
||||
One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing *what happened* and *what changed* at any real resolution — the data gets expensive and the compute gets brutal. That's the long game.
|
||||
|
||||
**Update — a hosted version is coming.** We originally planned to keep this repository as the open-source client and build a separate professional product. Then the launch happened, and the loudest request wasn't another feature — it was *"just give me a link."* So we're building an official hosted God's Eye View at [Halfpixel](https://halfpixel.ai): no installation, just open it in your browser. The hosted version is the easiest way into this open-source project. More soon.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
+13
@@ -218,3 +218,16 @@ 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.
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,312 @@
|
||||
# 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.
|
||||
+520
-11
@@ -1,7 +1,469 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
## 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
|
||||
30–240 second retry backoff. The top status and Contacts row explain the
|
||||
outage and scheduled countdown; an active retry says "Retrying mapped
|
||||
sites" and successful recovery clears the previous error. Known upstream
|
||||
rate limits, timeouts, and query failures are distinguished without exposing
|
||||
raw server errors. Failures from other loading layers retain precedence.
|
||||
- Click a selected installation again or click elsewhere on the map to clear
|
||||
its selection. Clearing the installation does not clear another layer's
|
||||
newly selected contact, and refreshes do not revive the cleared site.
|
||||
- Installation ways and relations without an explicit center use the midpoint
|
||||
of finite, ordered bounds spanning at most 10 degrees per axis. Explicit
|
||||
coordinates and centers retain precedence; invalid bounds are dropped.
|
||||
- Visual-style buttons describe their simulated effects on hover. Unavailable
|
||||
map-source tooltips and toasts share provider guidance: missing credentials
|
||||
point to Provider Settings, while a configured Google 3D route that fails
|
||||
points to restrictions, quota, or connectivity. These hints do not expose keys.
|
||||
|
||||
> **2026-08-23 — first-run mission launcher** (`src/firstRunExperience.js`,
|
||||
> `#first-run-launcher`, styles at the tail of `style.css`). After startup
|
||||
> settles, a fresh session gets one card offering **Live Contacts · Space
|
||||
@@ -729,7 +1191,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
|
||||
@@ -915,12 +1377,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
|
||||
@@ -1665,9 +2135,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
|
||||
@@ -2006,6 +2478,12 @@ silently demoting every later lookup for the session.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/ais-live/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/opensky-track` (OAuth, own credit bucket) and `/api/adsblol/trace` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
|
||||
### Overpass proxy mirror rotation (September 2026)
|
||||
|
||||
- `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache.
|
||||
- HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data.
|
||||
- A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`).
|
||||
|
||||
### Share-link v2 layer state (August 2026)
|
||||
|
||||
- Generated share links use a deterministic v2 hash. Existing camera, visual,
|
||||
@@ -2175,6 +2653,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`.
|
||||
@@ -2273,7 +2774,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.
|
||||
@@ -2586,3 +3087,11 @@ 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.
|
||||
@@ -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.
|
||||
+17
-14
@@ -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>
|
||||
@@ -492,37 +492,37 @@
|
||||
<div id="control-panel-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="style-buttons" class="button-grid">
|
||||
<button class="style-btn active" data-style="normal">
|
||||
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter.">
|
||||
<span class="btn-icon">◯</span>
|
||||
<span class="btn-label">Normal</span>
|
||||
<span class="btn-key">1</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="retro">
|
||||
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature.">
|
||||
<span class="btn-icon">▦</span>
|
||||
<span class="btn-label">CRT</span>
|
||||
<span class="btn-key">2</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="surveillance">
|
||||
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette.">
|
||||
<span class="btn-icon">🌙</span>
|
||||
<span class="btn-label">NVG</span>
|
||||
<span class="btn-key">3</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="thermal">
|
||||
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color.">
|
||||
<span class="btn-icon">🌡️</span>
|
||||
<span class="btn-label">FLIR</span>
|
||||
<span class="btn-key">4</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="anime">
|
||||
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines.">
|
||||
<span class="btn-icon">✦</span>
|
||||
<span class="btn-label">Anime</span>
|
||||
<span class="btn-key">5</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="noir">
|
||||
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading.">
|
||||
<span class="btn-icon">◐</span>
|
||||
<span class="btn-label">Noir</span>
|
||||
<span class="btn-key">6</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="snow">
|
||||
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene.">
|
||||
<span class="btn-icon">❄</span>
|
||||
<span class="btn-label">Snow</span>
|
||||
<span class="btn-key">7</span>
|
||||
@@ -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
+82
-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,85 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -30,6 +30,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 +226,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
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
[
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/check-package-boundaries.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/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"
|
||||
]
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
{
|
||||
"infrastructure": {
|
||||
"exports": [
|
||||
"./infrastructure",
|
||||
"./infrastructure/geojson",
|
||||
"./infrastructure/lod"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"application": {
|
||||
"exports": ["./application"],
|
||||
"modules": ["src/app/application.js"],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vite-build": {
|
||||
"runtime": "node",
|
||||
"exports": ["./build/vite"],
|
||||
"modules": ["build/vite.js"],
|
||||
"external": ["vite-plugin-cesium"]
|
||||
},
|
||||
"live-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/live"],
|
||||
"modules": [
|
||||
"server/providers/live.js",
|
||||
"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",
|
||||
"src/data/adsbLolFallback.js",
|
||||
"src/data/aisStreamAdapter.js",
|
||||
"src/data/aisWatchdog.js"
|
||||
],
|
||||
"external": ["ws"]
|
||||
},
|
||||
"adsb-lol-source": {
|
||||
"exports": ["./sources/adsb-lol"],
|
||||
"modules": ["src/data/adsbLolFallback.js"],
|
||||
"external": []
|
||||
},
|
||||
"place-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/places"],
|
||||
"modules": [
|
||||
"server/providers/places.js",
|
||||
"server/providers/places/google.js",
|
||||
"server/providers/places/google-key.js",
|
||||
"server/providers/places/routes.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
"scripts/google-server-key.mjs",
|
||||
"src/data/placeProviderPayloads.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"place-payloads": {
|
||||
"exports": ["./sources/places"],
|
||||
"modules": ["src/data/placeProviderPayloads.js"],
|
||||
"external": []
|
||||
},
|
||||
"space-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/space"],
|
||||
"modules": [
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
"server/providers/space/launch-library.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/data/spaceProviderRequests.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"space-requests": {
|
||||
"exports": ["./sources/space"],
|
||||
"modules": ["src/data/spaceProviderRequests.js"],
|
||||
"external": []
|
||||
},
|
||||
"terrain-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"terrain-source": {
|
||||
"exports": ["./sources/terrain"],
|
||||
"modules": ["src/data/terrainHeightsProxy.js"],
|
||||
"external": []
|
||||
},
|
||||
"traffic-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/traffic"],
|
||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source": {
|
||||
"exports": ["./sources/traffic"],
|
||||
"modules": ["src/data/tomtomTiles.js"],
|
||||
"external": []
|
||||
},
|
||||
"firms-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/firms"],
|
||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/gbfs"],
|
||||
"modules": [
|
||||
"server/providers/gbfs.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/data/gbfsSource.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-source": {
|
||||
"exports": ["./sources/gbfs"],
|
||||
"modules": ["src/data/gbfsSource.js"],
|
||||
"external": []
|
||||
},
|
||||
"cctv-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"modules": [
|
||||
"server/providers/cctv.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",
|
||||
"src/data/directionText.js",
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/places/google-key.js",
|
||||
"scripts/google-server-key.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"radio-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/radio"],
|
||||
"modules": [
|
||||
"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/data/radioCountry.js",
|
||||
"server/providers/common/http.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"overpass-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/overpass"],
|
||||
"modules": [
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.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/places/routes.js",
|
||||
"src/data/placeProviderPayloads.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"military-installations-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/military-installations"],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
"server/providers/common/rate-limit.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/overpass/constants.js",
|
||||
"server/providers/overpass/geometry.js",
|
||||
"server/providers/overpass/transport.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"regional-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/regional"],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
"server/providers/common/rate-limit.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",
|
||||
"src/data/regionalBrief.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"openai-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/openai"],
|
||||
"modules": [
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.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",
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/voice/voiceCost.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"standalone-key-setup": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/standalone/key-setup"],
|
||||
"modules": [
|
||||
"scripts/pinokio-environment.mjs",
|
||||
"server/providers/common/source-root.js",
|
||||
"server/standalone/key-setup.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/keySetupHardening.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"place-search": {
|
||||
"exports": ["./search"],
|
||||
"modules": [
|
||||
"src/search/index.js",
|
||||
"src/search/placeSearch.js",
|
||||
"src/search/google.js",
|
||||
"src/keylessGeocoder.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"panel-disclosure": {
|
||||
"exports": ["./ui/panels"],
|
||||
"modules": ["src/ui/panelDisclosure.js"],
|
||||
"external": []
|
||||
},
|
||||
"surface-keyboard": {
|
||||
"exports": ["./ui/surfaces"],
|
||||
"modules": ["src/ui/surfaceKeyboard.js"],
|
||||
"external": []
|
||||
},
|
||||
"panel-rail-layout": {
|
||||
"exports": ["./ui/layout"],
|
||||
"modules": [
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/panelStackLayout.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"visual-input": {
|
||||
"exports": ["./ui/input"],
|
||||
"modules": [
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/styleParameters.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"display-controls": {
|
||||
"exports": ["./ui/display"],
|
||||
"modules": ["src/ui/displayControls.js"],
|
||||
"external": []
|
||||
},
|
||||
"visual-effects": {
|
||||
"exports": ["./ui/effects", "./ui/effects/bloom"],
|
||||
"modules": [
|
||||
"src/ui/effects.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/ui/visualPresets.js",
|
||||
"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"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"map-source-controls": {
|
||||
"exports": ["./ui/maps"],
|
||||
"modules": [
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/mapStackChips.js",
|
||||
"src/keySetupCore.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"layer-panel": {
|
||||
"exports": ["./ui/layers", "./ui/layers/feedback"],
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"location-controls": {
|
||||
"exports": ["./ui/location"],
|
||||
"modules": [
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
"src/ui/locationSearch.js",
|
||||
"src/locationStatus.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"radio-controls": {
|
||||
"exports": ["./ui/radio"],
|
||||
"modules": [
|
||||
"src/ui/radio.js",
|
||||
"src/ui/radioControls.js",
|
||||
"src/ui/radioBindings.js",
|
||||
"src/ui/radioPresentation.js",
|
||||
"src/ui/radioTunerModel.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
"modules": [
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvPresentation.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"context-controls": {
|
||||
"exports": ["./ui/context", "./ui/context/policy"],
|
||||
"modules": [
|
||||
"src/ui/context.js",
|
||||
"src/ui/contextActions.js",
|
||||
"src/ui/contextBindings.js",
|
||||
"src/ui/contextControls.js",
|
||||
"src/ui/contextLayerChanges.js",
|
||||
"src/ui/contextPresentation.js",
|
||||
"src/ui/contextSession.js",
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/ui/panelRailGeometry.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
@@ -94,7 +94,9 @@ const getOpt = (name, dflt) => {
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const APP_URL = getOpt('--url', BASE_URL);
|
||||
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
|
||||
CCTV_URL.searchParams.set('welcome', '0');
|
||||
const APP_URL = CCTV_URL.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
||||
|
||||
@@ -106,7 +108,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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+352
-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,43 @@ 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;
|
||||
}
|
||||
// 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 +152,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 +184,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 +281,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 +298,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 +382,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 +390,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 +482,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 +498,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 +567,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 +707,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 +725,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 +763,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 +810,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 +1001,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 +1014,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 +1027,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 +1039,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 +1060,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 +1091,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 +1206,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 +1224,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 +1254,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 +1268,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 +1348,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 +1435,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 +2016,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',
|
||||
|
||||
@@ -52,7 +52,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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,242 @@
|
||||
#!/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 = [];
|
||||
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(
|
||||
'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;
|
||||
+916
-22
File diff suppressed because it is too large.
Load diff
+210
-111
@@ -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,
|
||||
@@ -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,18 @@ 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 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));
|
||||
// The synthetic shell below stops the aircraft/context update loop.
|
||||
// Restore its layout pass after the HUD fade before measuring geometry;
|
||||
// qa-cockpit-utility exercises scheduling with the real controller.
|
||||
manager.cockpitView.scheduleContextLayout();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const prior = {
|
||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||
cockpitActive: manager.cockpitView.active,
|
||||
@@ -2002,7 +2086,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
|
||||
@@ -2057,7 +2141,6 @@ async function main() {
|
||||
};
|
||||
// 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));
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
recordLayoutStep();
|
||||
if (index < 4) {
|
||||
@@ -2208,19 +2291,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,
|
||||
@@ -2652,9 +2739,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 +2812,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 +2838,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 +2858,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 +2910,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 +2927,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 +2941,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 +3065,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 +3148,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, {
|
||||
@@ -3106,18 +3199,18 @@ async function main() {
|
||||
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 +3228,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 +3276,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 +3292,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 +3331,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 +3349,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 +3372,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 +3387,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 +3415,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 +3447,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 +3470,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 +3483,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 +3501,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 +3521,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 +3546,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 +3596,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 +3791,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 +4486,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());
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -53,7 +53,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,154 @@
|
||||
#!/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 = {};
|
||||
const watchObserver = (name) => {
|
||||
const observer = ui[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 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);
|
||||
};
|
||||
try {
|
||||
await ui.dispose();
|
||||
const once = JSON.stringify(counts);
|
||||
await ui.dispose();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
return {
|
||||
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 && ui[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,
|
||||
idempotent: once === JSON.stringify(counts),
|
||||
};
|
||||
} finally {
|
||||
window.removeEventListener = removeEventListener;
|
||||
}
|
||||
});
|
||||
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('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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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,228 @@
|
||||
#!/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 result = manager.setBloom({ enabled: true, intensityPct: 98 });
|
||||
const snapshot = manager.getVisualState();
|
||||
return (
|
||||
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',
|
||||
|
||||
@@ -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,407 @@
|
||||
import { createCctvCatalog } from './cctv/catalog.js';
|
||||
import {
|
||||
normalizeFeedType,
|
||||
isVideoFeedType,
|
||||
toFiniteNumber,
|
||||
} from './cctv/normalize.js';
|
||||
import {
|
||||
buildSyntheticCctvSvg,
|
||||
proxyMediaResponse,
|
||||
fetchCctvImageFromUpstream,
|
||||
fetchCctvMediaUpstream,
|
||||
} from './cctv/media.js';
|
||||
import { CCTV_FRAME_FETCH_TIMEOUT_MS } 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 cover the
|
||||
* full served catalog (CCTV_MAX_SOURCES hard-bounds at 1200) so health/status
|
||||
* observability isn't silently evicted for a default 800-camera catalog. */
|
||||
const HEALTH_MAX_ENTRIES = 1200;
|
||||
|
||||
/** 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,
|
||||
})),
|
||||
};
|
||||
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 =
|
||||
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,181 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_CCTV_SOURCE_FILE,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
} from './constants.js';
|
||||
import { normalizeSourceItem } from './normalize.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
loadTflSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
/**
|
||||
* 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 sources from three origins (Austin Open Data, local file,
|
||||
* env variable), deduplicates by ID, applies the global max cap, 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 (Austin + Caltrans + TfL) load unless a file/env pack
|
||||
// is configured and live packs aren't forced — same gate that governed the
|
||||
// Austin-only fetch, now governing all three. Each pack fails independently.
|
||||
const needsLiveSources =
|
||||
forceAustin || (fromFile.length + fromEnv.length === 0 && preferAustin);
|
||||
const tflEnabled =
|
||||
String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0';
|
||||
|
||||
let fromAustin = [];
|
||||
let fromCaltrans = [];
|
||||
let fromTfl = [];
|
||||
if (needsLiveSources) {
|
||||
const [austinResult, caltransResult, tflResult] =
|
||||
await Promise.allSettled([
|
||||
loadAustinSourcesFromOpenData(),
|
||||
loadCaltransSourcesFromOpenData(),
|
||||
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
|
||||
]);
|
||||
fromAustin =
|
||||
austinResult.status === 'fulfilled' ? austinResult.value : [];
|
||||
fromCaltrans =
|
||||
caltransResult.status === 'fulfilled' ? caltransResult.value : [];
|
||||
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
|
||||
}
|
||||
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
|
||||
const merged = [
|
||||
...fromAustin,
|
||||
...fromCaltrans,
|
||||
...fromTfl,
|
||||
...fromFile,
|
||||
...fromEnv,
|
||||
];
|
||||
|
||||
// Deduplicate by camera ID (last-write wins because of Map.set)
|
||||
const byId = new Map();
|
||||
for (const item of merged) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const normalized = normalizeSourceItem(item);
|
||||
if (!normalized.id) continue;
|
||||
byId.set(normalized.id, normalized);
|
||||
}
|
||||
|
||||
const mergedSources = Array.from(byId.values());
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_MAX_SOURCES || DEFAULT_CCTV_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
|
||||
: DEFAULT_CCTV_MAX_SOURCES;
|
||||
if (mergedSources.length > maxCount) {
|
||||
console.warn(
|
||||
`[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`,
|
||||
);
|
||||
}
|
||||
const capped =
|
||||
mergedSources.length > maxCount
|
||||
? mergedSources.slice(0, maxCount)
|
||||
: mergedSources;
|
||||
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,48 @@
|
||||
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;
|
||||
/** Global cap on total CCTV sources served by the proxy. */
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 900;
|
||||
/** 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. */
|
||||
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 };
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL) 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,289 @@
|
||||
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,
|
||||
} 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 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 fetchImpl(url, {
|
||||
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
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,421 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
!Number.isFinite(maxCount) ||
|
||||
maxCount <= 0 ||
|
||||
list.length <= maxCount ||
|
||||
!anchorList.length
|
||||
) {
|
||||
return list;
|
||||
}
|
||||
|
||||
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, maxCount).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 || ''),
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
DEFAULT_AUSTIN_ROWS_URL,
|
||||
DEFAULT_AUSTIN_MAX_SOURCES,
|
||||
AUSTIN_DOWNTOWN,
|
||||
CALTRANS_CCTV_URL,
|
||||
DEFAULT_CALTRANS_DISTRICTS,
|
||||
DEFAULT_CALTRANS_MAX_SOURCES,
|
||||
CALTRANS_ANCHORS,
|
||||
TFL_JAMCAM_URL,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
DEFAULT_TFL_MAX_SOURCES,
|
||||
LONDON_CENTER,
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
extractAustinCoords,
|
||||
extractAustinCameraId,
|
||||
extractAustinName,
|
||||
extractAustinHeading,
|
||||
isLikelyAustinCoordinate,
|
||||
fallbackHeadingFromId,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
} from './normalize.js';
|
||||
import { directionToHeading } from '../../../src/data/directionText.js';
|
||||
/**
|
||||
* Fetch and parse Austin traffic camera records from the city Open Data portal.
|
||||
*
|
||||
* Downloads the Socrata rows.json payload, converts each row to a keyed
|
||||
* record, extracts camera ID / coords / heading / name, validates against
|
||||
* the Austin bounding box, deduplicates by ID, then distance-prioritizes
|
||||
* to stay within CCTV_AUSTIN_MAX_SOURCES.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadAustinSourcesFromOpenData() {
|
||||
const endpoint = process.env.CCTV_AUSTIN_ROWS_URL || DEFAULT_AUSTIN_ROWS_URL;
|
||||
try {
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Austin source download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const payload = await resp.json();
|
||||
const columns = Array.isArray(payload?.meta?.view?.columns)
|
||||
? payload.meta.view.columns
|
||||
: [];
|
||||
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
if (!columns.length || !rows.length) return [];
|
||||
|
||||
const cameras = [];
|
||||
for (const row of rows) {
|
||||
if (!Array.isArray(row)) continue;
|
||||
const record = rowArrayToObject(row, columns);
|
||||
const cameraId = extractAustinCameraId(record);
|
||||
if (!cameraId) continue;
|
||||
|
||||
// Only live cameras: the dataset carries DESIRED (planned, not built),
|
||||
// REMOVED and VOID rows whose frame URLs never resolve — those cameras
|
||||
// would render as permanent Street View / synthetic fallbacks. Tolerate
|
||||
// a missing column (keep the row) so a schema change fails open.
|
||||
const status = String(record.camera_status || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (status && status !== 'TURNED_ON') continue;
|
||||
|
||||
const { lat, lon } = extractAustinCoords(record);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (!isLikelyAustinCoordinate(lat, lon)) continue;
|
||||
|
||||
const extractedHeading = extractAustinHeading(record);
|
||||
const hasHeading = Number.isFinite(extractedHeading);
|
||||
const headingDeg = hasHeading
|
||||
? extractedHeading
|
||||
: fallbackHeadingFromId(cameraId);
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: extractAustinName(record, cameraId),
|
||||
city: 'Austin',
|
||||
cityId: 'austin',
|
||||
provider: 'Austin Transportation & Public Works',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg,
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: 150,
|
||||
feedType: 'image',
|
||||
url: `https://cctv.austinmobility.io/image/${encodeURIComponent(cameraId)}.jpg`,
|
||||
snapshotUrl: `https://cctv.austinmobility.io/image/${encodeURIComponent(cameraId)}.jpg`,
|
||||
sourceKind: 'austin-open-data',
|
||||
license: 'Public city traffic camera frame',
|
||||
});
|
||||
}
|
||||
|
||||
const unique = Array.from(
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_AUSTIN_MAX_SOURCES || DEFAULT_AUSTIN_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(300, Math.floor(maxRaw)))
|
||||
: DEFAULT_AUSTIN_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(unique, maxCount, [AUSTIN_DOWNTOWN]);
|
||||
if (prioritized.length < unique.length) {
|
||||
console.log(
|
||||
`[CCTV] Loaded Austin camera sources: ${unique.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
} else {
|
||||
console.log('[CCTV] Loaded Austin camera sources:', prioritized.length);
|
||||
}
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Austin source download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Caltrans CCTV cameras for the configured districts (CCTV_CALTRANS_DISTRICTS,
|
||||
* comma-separated 1..12; empty string disables the pack). One official JSON feed per
|
||||
* district, identical schema statewide; keyless. Only inService cameras with finite
|
||||
* coords and a cwwp2.dot.ca.gov https image URL are kept (the image-URL origin check
|
||||
* is defense-in-depth: the proxy only ever fetches catalog URLs, and this pins the
|
||||
* catalog to the official host). Districts fetch in parallel and fail independently
|
||||
* (Promise.allSettled) — one district outage never darkens the others.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadCaltransSourcesFromOpenData() {
|
||||
const districtsRaw =
|
||||
process.env.CCTV_CALTRANS_DISTRICTS ?? DEFAULT_CALTRANS_DISTRICTS;
|
||||
const districts = String(districtsRaw)
|
||||
.split(',')
|
||||
.map((token) => Number(token.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 12);
|
||||
if (!districts.length) return [];
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
districts.map(async (district) => {
|
||||
const resp = await fetch(CALTRANS_CCTV_URL(district), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`D${district} HTTP ${resp.status}`);
|
||||
const payload = await resp.json();
|
||||
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return { district, rows };
|
||||
}),
|
||||
);
|
||||
|
||||
const cameras = [];
|
||||
for (const result of settled) {
|
||||
if (result.status !== 'fulfilled') {
|
||||
console.warn(
|
||||
'[CCTV] Caltrans district fetch failed:',
|
||||
result.reason?.message || result.reason,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { district, rows } = result.value;
|
||||
for (const row of rows) {
|
||||
const cctv = row?.cctv;
|
||||
if (!cctv || String(cctv.inService).toLowerCase() !== 'true') continue;
|
||||
const loc = cctv.location || {};
|
||||
const lat = toFiniteNumber(loc.latitude);
|
||||
const lon = toFiniteNumber(loc.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
const imageUrl = String(cctv.imageData?.static?.currentImageURL || '');
|
||||
// Official-host pin (see JSDoc). Also drops records with no still image.
|
||||
if (!imageUrl.startsWith('https://cwwp2.dot.ca.gov/')) continue;
|
||||
|
||||
const locationName = String(loc.locationName || '').trim();
|
||||
// Leading token of locationName is the stable camera code ("TV102 -- I-580 : …").
|
||||
const codeMatch = /^([A-Za-z0-9_-]+)\s*--/.exec(locationName);
|
||||
const code = (
|
||||
codeMatch ? codeMatch[1] : `x${cameras.length}`
|
||||
).toLowerCase();
|
||||
const cameraId = `ca-d${district}-${code}`;
|
||||
|
||||
// loc.direction is a dedicated field ("West", "South") → allow bare words.
|
||||
const heading = directionToHeading(loc.direction, true);
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
const label =
|
||||
locationName.replace(/^([A-Za-z0-9_-]+)\s*--\s*/, '') ||
|
||||
`Caltrans D${district} ${code}`;
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: loc.nearbyPlace ? `${label} (${loc.nearbyPlace})` : label,
|
||||
city: String(loc.nearbyPlace || `Caltrans D${district}`),
|
||||
cityId: `ca-d${district}`,
|
||||
provider: 'Caltrans',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
// Same two fabricated pose personalities as Austin (design §1a): these are
|
||||
// RAW PRIOR starting points; the client's one-shot ground snap + manual
|
||||
// calibration own the truth.
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
// loc.elevation is reported in FEET (verified: D3 maxes at 7427 ft ≈
|
||||
// 2264 m for the Sierra passes — as metres that would top Mt Whitney).
|
||||
// Convert to metres and clamp to a sane CA-roads range so an occasional
|
||||
// garbage upstream value can't fling a camera kilometres up. Prior only:
|
||||
// the client one-shot snap corrects it on 3D-tile stacks — but on a
|
||||
// no-tileset stack (keyless OSM) the snap misses and this height freezes,
|
||||
// so it must be right-ish on its own.
|
||||
groundElevationM: (() => {
|
||||
const ft = toFiniteNumber(loc.elevation, NaN);
|
||||
return Number.isFinite(ft)
|
||||
? Math.max(-100, Math.min(4000, ft * 0.3048))
|
||||
: 150;
|
||||
})(),
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'caltrans-open-data',
|
||||
license: 'Public Caltrans highway camera frame',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_CALTRANS_MAX_SOURCES || DEFAULT_CALTRANS_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
|
||||
: DEFAULT_CALTRANS_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, CALTRANS_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Caltrans camera sources: ${cameras.length} inService (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch TfL JamCams (London). Keyless: the optional TFL_APP_KEY only raises the
|
||||
* list-endpoint rate limit (frames come from TfL's public S3 bucket, which is not
|
||||
* rate-limited); the 15-min source cache keeps list hits far below anonymous
|
||||
* limits anyway. Only `available === "true"` cameras with finite coords and an
|
||||
* image URL on the official bucket are kept. Attribution: "Powered by TfL Open
|
||||
* Data" (registered in src/data/dataCredits.js).
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadTflSourcesFromOpenData() {
|
||||
try {
|
||||
const appKey = String(process.env.TFL_APP_KEY || '').trim();
|
||||
const url = appKey
|
||||
? `${TFL_JAMCAM_URL}?app_key=${encodeURIComponent(appKey)}`
|
||||
: TFL_JAMCAM_URL;
|
||||
const resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] TfL JamCam download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const places = await resp.json();
|
||||
if (!Array.isArray(places)) return [];
|
||||
|
||||
const cameras = [];
|
||||
for (const place of places) {
|
||||
const props = {};
|
||||
for (const p of place?.additionalProperties || []) {
|
||||
if (p?.key) props[p.key] = p.value;
|
||||
}
|
||||
if (String(props.available).toLowerCase() !== 'true') continue;
|
||||
const lat = toFiniteNumber(place?.lat);
|
||||
const lon = toFiniteNumber(place?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
const imageUrl = String(props.imageUrl || '');
|
||||
if (!imageUrl.startsWith(TFL_IMAGE_ORIGIN)) continue; // official-bucket pin
|
||||
|
||||
// "JamCams_00002.00865" → "tfl-00002.00865" (provider-stable id).
|
||||
const rawId = String(place?.id || '').replace(/^JamCams_/, '');
|
||||
if (!rawId) continue;
|
||||
const cameraId = `tfl-${rawId}`;
|
||||
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: String(place?.commonName || `JamCam ${rawId}`),
|
||||
city: 'London',
|
||||
cityId: 'london',
|
||||
provider: 'Transport for London',
|
||||
lat,
|
||||
lon,
|
||||
// No heading signal at all in JamCam data → id-hash fallback, low
|
||||
// confidence personality (same as headingless Austin cameras).
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 15, // Thames-basin prior; one-shot snap corrects.
|
||||
feedType: 'image', // stills-first (owner decision); props.videoUrl deliberately unused
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'tfl-open-data',
|
||||
license: 'Powered by TfL Open Data',
|
||||
});
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_TFL_MAX_SOURCES || DEFAULT_TFL_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
|
||||
: DEFAULT_TFL_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, [LONDON_CENTER]);
|
||||
console.log(
|
||||
`[CCTV] Loaded TfL JamCam sources: ${cameras.length} available (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] TfL JamCam download error:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
readResponseTextCapped,
|
||||
readResponseJsonCapped,
|
||||
coalesceProxyRequest,
|
||||
readCappedResponseText,
|
||||
} from './common/http.js';
|
||||
export { requiredFiniteQueryNumber, clampInt } from './common/query.js';
|
||||
export { adsbLolFallbackAnchor, openSkyProxy } from './aircraft/opensky.js';
|
||||
export { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||
export { adsbdbProxy } from './aircraft/enrichment.js';
|
||||
export { trackBackfillProxies } from './aircraft/tracks.js';
|
||||
export { aisLiveProxy } from './vessels/ais-live.js';
|
||||
@@ -0,0 +1,89 @@
|
||||
import { openSkyProxy } from './aircraft/opensky.js';
|
||||
import { celestrakProxy, rocketLaunchesProxy } from './space.js';
|
||||
import { tomtomProxy } from './traffic.js';
|
||||
import { firmsProxy } from './firms.js';
|
||||
import { terrainHeightsProxy } from './terrain.js';
|
||||
import { adsbdbProxy } from './aircraft/enrichment.js';
|
||||
import { overpassProxy } from './overpass.js';
|
||||
import { militaryInstallationsProxy } from './military-installations.js';
|
||||
import { regionalBriefProxy } from './regional/briefing.js';
|
||||
import { weatherEffectsProxy } from './regional/weather-effects.js';
|
||||
import { cctvProxy } from './cctv.js';
|
||||
import { defaultSourceRoot } from './common/source-root.js';
|
||||
import { radioBrowserProxy } from './radio.js';
|
||||
import { gbfsProxy } from './gbfs.js';
|
||||
import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||
import { aisLiveProxy } from './vessels/ais-live.js';
|
||||
import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||
import { openAiRealtimeProxy } from './openai.js';
|
||||
import { googlePlacesContextProxy } from './places.js';
|
||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
function localProviderPlugins() {
|
||||
return [
|
||||
openSkyProxy(),
|
||||
celestrakProxy(),
|
||||
tomtomProxy(),
|
||||
firmsProxy(),
|
||||
rocketLaunchesProxy(),
|
||||
terrainHeightsProxy(),
|
||||
adsbdbProxy(),
|
||||
overpassProxy(),
|
||||
militaryInstallationsProxy(),
|
||||
regionalBriefProxy(),
|
||||
weatherEffectsProxy(),
|
||||
cctvProxy({ sourceRoot: defaultSourceRoot }),
|
||||
radioBrowserProxy(),
|
||||
gbfsProxy(),
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
export { localProviderPlugins };
|
||||
|
||||
export {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from './cctv.js';
|
||||
export {
|
||||
createRadioProxyMiddleware,
|
||||
isPublicRadioAddress,
|
||||
normalizeRadioBrowserStation,
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from './radio.js';
|
||||
export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
|
||||
export { googlePlacesContextProxy } from './places.js';
|
||||
export { googleServerApiKey } from './places.js';
|
||||
export { keylessGooglePlacesResponse } from './places.js';
|
||||
export { adsbLolFallbackAnchor } from './aircraft/opensky.js';
|
||||
export { readResponseTextCapped } from './common/http.js';
|
||||
export { readResponseJsonCapped } from './common/http.js';
|
||||
export { coalesceProxyRequest } from './common/http.js';
|
||||
export { requiredFiniteQueryNumber } from './common/query.js';
|
||||
export { isOverpassBoundaryQuery } from './overpass/query.js';
|
||||
export { simplifyOverpassPayloadBody } from './overpass/geometry.js';
|
||||
export { readOverpassDisk } from './overpass/cache.js';
|
||||
export { resolveOverpassPreflight } from './overpass/cache.js';
|
||||
export { overpassPayloadIsData } from './overpass/transport.js';
|
||||
export { fetchOverpassPayload } from './overpass/transport.js';
|
||||
export { openAiRealtimeProxy } from './openai.js';
|
||||
export { MILITARY_INSTALLATION_ELEMENT_CAP } from './military-installations/constants.js';
|
||||
export { quantizeMilitaryInstallationBox } from './military-installations/query.js';
|
||||
export { militaryInstallationCacheKey } from './military-installations/query.js';
|
||||
export { resolveMilitaryInstallationTier } from './military-installations/cache.js';
|
||||
export { migrateMilitaryInstallationEntry } from './military-installations/cache.js';
|
||||
export { militaryInstallationDiskFresh } from './military-installations/cache.js';
|
||||
export { militaryInstallationDiskPath } from './military-installations/cache.js';
|
||||
export { readMilitaryInstallationDisk } from './military-installations/cache.js';
|
||||
export { writeMilitaryInstallationDisk } from './military-installations/cache.js';
|
||||
export { validMilitaryInstallationBox } from './military-installations/query.js';
|
||||
export { militaryInstallationFailureReason } from './military-installations/query.js';
|
||||
export { validRegionalPoint } from './regional/query.js';
|
||||
export { regionalBriefHasAnySource } from './regional/briefing.js';
|
||||
@@ -0,0 +1,215 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import {
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
} from './military-installations/constants.js';
|
||||
import { fetchOverpassPayload } from './overpass/transport.js';
|
||||
import {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
writeMilitaryInstallationDisk,
|
||||
resolveMilitaryInstallationTier,
|
||||
readMilitaryInstallationDisk,
|
||||
} from './military-installations/cache.js';
|
||||
import {
|
||||
validMilitaryInstallationBox,
|
||||
quantizeMilitaryInstallationBox,
|
||||
militaryInstallationCacheKey,
|
||||
militaryInstallationFailureReason,
|
||||
} from './military-installations/query.js';
|
||||
import { coalesceProxyRequest } from './common/http.js';
|
||||
|
||||
const _militaryInstallationsRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 90,
|
||||
globalMax: 300,
|
||||
});
|
||||
|
||||
const _militaryInstallationInFlight = new Map();
|
||||
|
||||
function militaryInstallationsProxy() {
|
||||
async function refresh(box, key) {
|
||||
const bbox = `${box.south},${box.west},${box.north},${box.east}`;
|
||||
const ql = `[out:json][timeout:20];(nwr["military"~"^(airfield|naval_base|range|barracks|base)$"](${bbox});nwr["landuse"="military"](${bbox}););out center tags geom ${MILITARY_INSTALLATION_ELEMENT_CAP};`;
|
||||
const upstream = await fetchOverpassPayload(
|
||||
`data=${encodeURIComponent(ql)}`,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (
|
||||
upstream.status >= 400 ||
|
||||
upstream.rateLimited ||
|
||||
upstream.runtimeError
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error('Mapped installation upstream unavailable'),
|
||||
{
|
||||
installationReason: upstream.rateLimited
|
||||
? 'rate_limited'
|
||||
: upstream.status === 504
|
||||
? 'timeout'
|
||||
: upstream.runtimeError
|
||||
? 'query_failed'
|
||||
: 'unavailable',
|
||||
},
|
||||
);
|
||||
}
|
||||
const parsed = JSON.parse(upstream.body);
|
||||
const elements = Array.isArray(parsed?.elements)
|
||||
? parsed.elements.slice(0, MILITARY_INSTALLATION_ELEMENT_CAP)
|
||||
: [];
|
||||
const payload = {
|
||||
elements,
|
||||
// Honest truncation flag — the client re-asks for its exact viewport so
|
||||
// off-view features can never starve in-view ones. The cap travels with
|
||||
// the payload so the client never has to hard-code it.
|
||||
saturated: elements.length >= MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
elementCap: MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
retrievedAt: new Date().toISOString(),
|
||||
status: 'ready',
|
||||
};
|
||||
const entry = { payload, cachedAt: Date.now() };
|
||||
_militaryInstallationCache.set(key, entry);
|
||||
trimMilitaryInstallationCache();
|
||||
writeMilitaryInstallationDisk(key, entry);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/military-installations', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!_militaryInstallationsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const requested = validMilitaryInstallationBox(url.searchParams);
|
||||
if (!requested) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'A non-dateline bbox no larger than 10 degrees is required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Query the SNAPPED box, not the raw viewport: neighbouring views then
|
||||
// share one cache entry, and an outward snap always covers what was asked.
|
||||
// `exact=1` opts out — the client sends it after a SATURATED snapped
|
||||
// response, so a truncated tile can never starve the actual viewport. It
|
||||
// is keyed separately so exact and snapped answers never collide.
|
||||
const exact = url.searchParams.get('exact') === '1';
|
||||
const box = exact
|
||||
? requested
|
||||
: quantizeMilitaryInstallationBox(requested);
|
||||
// Key at the precision the query actually uses (see militaryInstallationCacheKey).
|
||||
const key = exact
|
||||
? `exact:${militaryInstallationCacheKey(box, 5)}`
|
||||
: militaryInstallationCacheKey(box);
|
||||
const now = Date.now();
|
||||
const cached = _militaryInstallationCache.get(key);
|
||||
const preflight = await resolveMilitaryInstallationTier({
|
||||
cacheKey: key,
|
||||
memoryCache: _militaryInstallationCache,
|
||||
inFlight: _militaryInstallationInFlight,
|
||||
readDisk: () =>
|
||||
readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS),
|
||||
now,
|
||||
});
|
||||
if (preflight.source !== 'UPSTREAM') {
|
||||
if (preflight.source === 'DISK') {
|
||||
_militaryInstallationCache.set(key, preflight.entry);
|
||||
trimMilitaryInstallationCache();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Military-Installations': preflight.source,
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ ...preflight.entry.payload, status: 'cached' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(
|
||||
_militaryInstallationInFlight,
|
||||
key,
|
||||
() => refresh(box, key),
|
||||
);
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Military-Installations': request.shared ? 'INFLIGHT' : 'MISS',
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
if (cached && now - cached.cachedAt <= MILITARY_INSTALLATION_STALE_MS) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': 'STALE',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
// Overpass is down: last-good mapped context at ANY age beats an empty
|
||||
// layer (the same serve-stale rule the Overpass proxy applies).
|
||||
const stale = await readMilitaryInstallationDisk(key, Infinity);
|
||||
if (stale) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': 'STALE-DISK',
|
||||
});
|
||||
res.end(JSON.stringify({ ...stale.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Mapped installation context is temporarily unavailable',
|
||||
reason: militaryInstallationFailureReason(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'military-installations-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { militaryInstallationsProxy };
|
||||
|
||||
export { MILITARY_INSTALLATION_ELEMENT_CAP } from './military-installations/constants.js';
|
||||
export { quantizeMilitaryInstallationBox } from './military-installations/query.js';
|
||||
export { militaryInstallationCacheKey } from './military-installations/query.js';
|
||||
export { resolveMilitaryInstallationTier } from './military-installations/cache.js';
|
||||
export { migrateMilitaryInstallationEntry } from './military-installations/cache.js';
|
||||
export { militaryInstallationDiskFresh } from './military-installations/cache.js';
|
||||
export { militaryInstallationDiskPath } from './military-installations/cache.js';
|
||||
export { readMilitaryInstallationDisk } from './military-installations/cache.js';
|
||||
export { writeMilitaryInstallationDisk } from './military-installations/cache.js';
|
||||
export { validMilitaryInstallationBox } from './military-installations/query.js';
|
||||
export { militaryInstallationFailureReason } from './military-installations/query.js';
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
MILITARY_INSTALLATION_CACHE_MS,
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_DISK_DIR,
|
||||
MILITARY_INSTALLATION_MAX_CACHE,
|
||||
} from './constants.js';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
|
||||
const _militaryInstallationCache = new Map();
|
||||
|
||||
/**
|
||||
* Resolve the READ tiers for one installation request, in order: fresh memory,
|
||||
* then disk. Returns UPSTREAM when neither can answer.
|
||||
*
|
||||
* Disk is skipped while a request for this key is already in flight — the
|
||||
* caller joins that instead of paying a read. Exported so the tier ORDER is
|
||||
* testable against a real temp directory without a Vite server, mirroring
|
||||
* resolveOverpassPreflight.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.cacheKey
|
||||
* @param {Map<string, {payload: object, cachedAt: number}>} options.memoryCache
|
||||
* @param {Map<string, Promise>} options.inFlight
|
||||
* @param {() => Promise<?{payload: object, cachedAt: number}>} options.readDisk
|
||||
* @param {number} [options.now]
|
||||
* @param {number} [options.cacheMs]
|
||||
* @returns {Promise<{source: 'HIT'|'DISK'|'UPSTREAM', entry: ?object}>}
|
||||
*/
|
||||
async function resolveMilitaryInstallationTier({
|
||||
cacheKey,
|
||||
memoryCache,
|
||||
inFlight,
|
||||
readDisk,
|
||||
now = Date.now(),
|
||||
cacheMs = MILITARY_INSTALLATION_CACHE_MS,
|
||||
}) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (cached && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', entry: cached };
|
||||
if (inFlight.has(cacheKey)) return { source: 'UPSTREAM', entry: null };
|
||||
const disk = await readDisk();
|
||||
return disk
|
||||
? { source: 'DISK', entry: disk }
|
||||
: { source: 'UPSTREAM', entry: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a stored installation entry up to the current payload shape.
|
||||
*
|
||||
* Entries written before the saturation guard shipped carry no `saturated`
|
||||
* field, and the disk TTL is 30 DAYS — so without this a cached, truncated
|
||||
* 700-element snapped response would keep skipping the exact-viewport retry for
|
||||
* a month, quietly starving in-view sites. Saturation is DERIVED from the
|
||||
* element count rather than invalidating those entries, so warm caches survive
|
||||
* the upgrade.
|
||||
* @param {?{payload: object, cachedAt: number}} entry
|
||||
* @returns {?{payload: object, cachedAt: number}}
|
||||
*/
|
||||
function migrateMilitaryInstallationEntry(entry) {
|
||||
if (!entry?.payload || typeof entry.payload.saturated === 'boolean')
|
||||
return entry;
|
||||
const elements = Array.isArray(entry.payload.elements)
|
||||
? entry.payload.elements
|
||||
: [];
|
||||
return {
|
||||
...entry,
|
||||
payload: {
|
||||
...entry.payload,
|
||||
saturated: elements.length >= MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a stored installation entry is still inside its TTL. */
|
||||
function militaryInstallationDiskFresh(
|
||||
entry,
|
||||
maxAgeMs = MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (
|
||||
!entry ||
|
||||
!Number.isFinite(entry.cachedAt) ||
|
||||
!Array.isArray(entry.payload?.elements)
|
||||
)
|
||||
return false;
|
||||
return now - entry.cachedAt <= maxAgeMs;
|
||||
}
|
||||
|
||||
/** Cache key -> stable disk-cache file path. */
|
||||
function militaryInstallationDiskPath(
|
||||
cacheKey,
|
||||
dir = MILITARY_INSTALLATION_DISK_DIR,
|
||||
) {
|
||||
return path.join(
|
||||
dir,
|
||||
`${createHash('sha1').update(cacheKey).digest('hex')}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a disk-cached installation entry. maxAgeMs Infinity = any age (the
|
||||
* serve-stale path when Overpass is down).
|
||||
* @returns {Promise<?{payload: object, cachedAt: number}>}
|
||||
*/
|
||||
async function readMilitaryInstallationDisk(
|
||||
cacheKey,
|
||||
maxAgeMs,
|
||||
dir = MILITARY_INSTALLATION_DISK_DIR,
|
||||
) {
|
||||
try {
|
||||
const entry = JSON.parse(
|
||||
await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'),
|
||||
);
|
||||
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
|
||||
return migrateMilitaryInstallationEntry(entry);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one installation payload ATOMICALLY: serialize to a temp sibling,
|
||||
* then rename over the target. A crash or a full disk mid-write leaves the
|
||||
* PREVIOUS entry intact — an in-place overwrite would shred the last-good copy
|
||||
* and take serve-stale down with it, exactly when it is needed most.
|
||||
* @returns {Promise<boolean>} Whether the entry landed.
|
||||
*/
|
||||
async function writeMilitaryInstallationDisk(
|
||||
cacheKey,
|
||||
entry,
|
||||
dir = MILITARY_INSTALLATION_DISK_DIR,
|
||||
) {
|
||||
const target = militaryInstallationDiskPath(cacheKey, dir);
|
||||
// Same directory, so the rename is atomic on POSIX rather than a cross-device copy.
|
||||
const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
try {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
await fsp.writeFile(temp, JSON.stringify(entry));
|
||||
await fsp.rename(temp, target);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[Installations Proxy] disk cache write failed:',
|
||||
err?.message || err,
|
||||
);
|
||||
await fsp.rm(temp, { force: true }).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function trimMilitaryInstallationCache() {
|
||||
while (_militaryInstallationCache.size > MILITARY_INSTALLATION_MAX_CACHE) {
|
||||
const oldest = _militaryInstallationCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
_militaryInstallationCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
writeMilitaryInstallationDisk,
|
||||
resolveMilitaryInstallationTier,
|
||||
readMilitaryInstallationDisk,
|
||||
migrateMilitaryInstallationEntry,
|
||||
militaryInstallationDiskFresh,
|
||||
militaryInstallationDiskPath,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Military-installation context proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
// This narrow endpoint deliberately does not expose arbitrary Overpass QL to
|
||||
// the browser. It returns only allow-listed mapped context and rejects global,
|
||||
// cross-dateline, or oversized requests before touching public OSM mirrors.
|
||||
const MILITARY_INSTALLATION_CACHE_MS = 5 * 60_000;
|
||||
|
||||
const MILITARY_INSTALLATION_STALE_MS = 60 * 60_000;
|
||||
|
||||
const MILITARY_INSTALLATION_MAX_CACHE = 80;
|
||||
|
||||
const MILITARY_INSTALLATION_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Upstream element cap. A response that hits it exactly is SATURATED — Overpass
|
||||
* truncated, so off-viewport features from the snapped bbox may have crowded out
|
||||
* in-viewport ones. Callers re-ask for the exact viewport in that case.
|
||||
*/
|
||||
const MILITARY_INSTALLATION_ELEMENT_CAP = 700;
|
||||
|
||||
/**
|
||||
* Disk-cache TTL for mapped installations (ms) — 30 days.
|
||||
*
|
||||
* Owner playtest 2026-08-18: "search nearby sites" was slow because every look
|
||||
* around paid a live Overpass round trip, and the 5-minute in-memory tier died
|
||||
* with the dev server. Mapped military features change on a survey timescale,
|
||||
* not a session one, so a month-old answer is still the right answer — the same
|
||||
* reasoning the Overpass proxy already applies to admin boundaries.
|
||||
*/
|
||||
const MILITARY_INSTALLATION_DISK_TTL_MS = 30 * 86_400_000;
|
||||
|
||||
/** Disk-cache directory for mapped installation payloads. */
|
||||
const MILITARY_INSTALLATION_DISK_DIR = path.join(
|
||||
process.cwd(),
|
||||
'.gev-cache',
|
||||
'military-installations',
|
||||
);
|
||||
|
||||
/**
|
||||
* Cache-key grid step in degrees (~5.5 km).
|
||||
*
|
||||
* The browser sends the raw view rectangle, so every pixel of pan minted a new
|
||||
* key and a new upstream query. Snapping the bbox OUTWARD onto a coarse grid
|
||||
* makes neighbouring viewports share one entry, and because the snap only ever
|
||||
* grows the box, the cached answer is always a superset of what was asked for.
|
||||
*/
|
||||
const MILITARY_INSTALLATION_BBOX_STEP_DEG = 0.05;
|
||||
|
||||
export {
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
MILITARY_INSTALLATION_CACHE_MS,
|
||||
MILITARY_INSTALLATION_DISK_DIR,
|
||||
MILITARY_INSTALLATION_MAX_CACHE,
|
||||
MILITARY_INSTALLATION_BBOX_STEP_DEG,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { MILITARY_INSTALLATION_BBOX_STEP_DEG } from './constants.js';
|
||||
import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||
|
||||
/**
|
||||
* Snap a request bbox outward onto the shared installation cache grid.
|
||||
* @param {{south:number, west:number, north:number, east:number}} box
|
||||
* @param {number} [stepDeg]
|
||||
* @returns {{south:number, west:number, north:number, east:number}}
|
||||
*/
|
||||
function quantizeMilitaryInstallationBox(
|
||||
box,
|
||||
stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG,
|
||||
) {
|
||||
// Round the ratio first: 29.9999/0.05 lands a hair under an exact grid line
|
||||
// in binary floating point, which would otherwise snap a whole cell too far.
|
||||
const snap = (value, grow) => {
|
||||
const cells = Number((value / stepDeg).toFixed(9));
|
||||
return Number(
|
||||
((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6),
|
||||
);
|
||||
};
|
||||
return {
|
||||
south: Math.max(-90, snap(box.south, -1)),
|
||||
west: Math.max(-180, snap(box.west, -1)),
|
||||
north: Math.min(90, snap(box.north, 1)),
|
||||
east: Math.min(180, snap(box.east, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable disk/memory cache key for an installation bbox.
|
||||
*
|
||||
* The key's precision must match the precision of the bounds the QUERY uses, or
|
||||
* two different queries collide on one entry. Snapped boxes live on a 0.05 deg
|
||||
* grid, so 3 decimals is exact for them; an `exact=1` request carries the raw
|
||||
* viewport at 5 decimals and must be keyed at 5, otherwise two nearby exact
|
||||
* viewports would share an answer and the second would be missing the edge
|
||||
* strip it just exposed.
|
||||
* @param {{south:number, west:number, north:number, east:number}} box
|
||||
* @param {number} [decimals]
|
||||
*/
|
||||
function militaryInstallationCacheKey(box, decimals = 3) {
|
||||
return [box.south, box.west, box.north, box.east]
|
||||
.map((value) => value.toFixed(decimals))
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function validMilitaryInstallationBox(params) {
|
||||
const south = requiredFiniteQueryNumber(params, 'south');
|
||||
const west = requiredFiniteQueryNumber(params, 'west');
|
||||
const north = requiredFiniteQueryNumber(params, 'north');
|
||||
const east = requiredFiniteQueryNumber(params, 'east');
|
||||
if (![south, west, north, east].every(Number.isFinite)) return null;
|
||||
if (
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
west < -180 ||
|
||||
east > 180 ||
|
||||
south >= north ||
|
||||
west >= east
|
||||
)
|
||||
return null;
|
||||
if (north - south > 10 || east - west > 10) return null;
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
/** Safe, evidence-based reason for an installation upstream failure. */
|
||||
function militaryInstallationFailureReason(error) {
|
||||
if (
|
||||
['rate_limited', 'timeout', 'query_failed'].includes(
|
||||
error?.installationReason,
|
||||
)
|
||||
)
|
||||
return error.installationReason;
|
||||
return ['AbortError', 'TimeoutError'].includes(error?.name)
|
||||
? 'timeout'
|
||||
: 'unavailable';
|
||||
}
|
||||
|
||||
export {
|
||||
validMilitaryInstallationBox,
|
||||
quantizeMilitaryInstallationBox,
|
||||
militaryInstallationCacheKey,
|
||||
militaryInstallationFailureReason,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defaultSourceRoot } from './common/source-root.js';
|
||||
import { handleHudSummary } from './openai/hud-summary.js';
|
||||
import { createDebugLogHandler } from './openai/debug-log.js';
|
||||
import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||
|
||||
/**
|
||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||
*
|
||||
* Keeps OPENAI_API_KEY server-side while the browser connects to the
|
||||
* Realtime API over WebRTC with a short-lived secret.
|
||||
*/
|
||||
function openAiRealtimeProxy({
|
||||
sourceRoot = defaultSourceRoot,
|
||||
annotationGuidance,
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/openai/hud-summary', handleHudSummary);
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/debug-log',
|
||||
createDebugLogHandler({ sourceRoot }),
|
||||
);
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/token',
|
||||
createRealtimeTokenHandler({ annotationGuidance }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'openai-realtime-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { openAiRealtimeProxy };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { VOICE_MODELS } from '../../../src/voice/voiceCost.js';
|
||||
|
||||
// Sourced from the shared voice-model registry so the client's cost estimate
|
||||
// can never be computed against a different model than the session runs on.
|
||||
const OPENAI_REALTIME_MODEL_DEFAULT = VOICE_MODELS.standard.id;
|
||||
|
||||
const OPENAI_REALTIME_MODEL_MINI_DEFAULT = VOICE_MODELS.mini.id;
|
||||
|
||||
const OPENAI_REALTIME_VOICE_DEFAULT = 'marin';
|
||||
|
||||
const OPENAI_REALTIME_REASONING_DEFAULT = 'low';
|
||||
|
||||
const OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT = 3000;
|
||||
|
||||
const OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT = 0.5;
|
||||
|
||||
const OPENAI_HUD_SUMMARY_MODEL_DEFAULT = 'gpt-5-nano';
|
||||
|
||||
export {
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_VOICE_DEFAULT,
|
||||
OPENAI_REALTIME_REASONING_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
|
||||
};
|
||||
Loaded 100 of 342 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user