Compare commits

..
Author SHA1 Message Date
Sameh Khamis 43db5c19bb Merge pull request #403 from bilawalsidhu/refactor/context-controls
refactor: separate Context controls and layer restoration
2026-09-13 02:18:04 -07:00
Sameh Khamis 0431dbb05e test: preserve the layout pass in the synthetic Cockpit fixture 2026-09-13 02:03:18 -07:00
Sameh Khamis b1e67a6a50 test: retain Context invariants across formatted module boundaries 2026-09-13 01:43:18 -07:00
Sameh Khamis 350a1afd4f style: format Context package boundary declaration 2026-09-13 01:43:18 -07:00
Sameh Khamis 3e078ef49d style: format Context component modules 2026-09-13 01:41:01 -07:00
Sameh Khamis fc369cc65a refactor: isolate Context controls and layer restoration 2026-09-13 01:40:59 -07:00
Sameh Khamis 7f70b97536 Merge pull request #400 from bilawalsidhu/refactor/camera-panel
refactor: separate camera panel controls and presentation
2026-09-13 01:26:53 -07:00
Sameh Khamis 1b9ea0e8e3 fix: preserve camera controls when rebuilding the Radio panel 2026-09-13 01:23:55 -07:00
Sameh Khamis e1c3045203 test: exercise camera controls and stale frame handling in the browser 2026-09-13 01:16:53 -07:00
Sameh Khamis c6ac74c242 test: follow the camera panel presentation owner 2026-09-13 00:45:35 -07:00
Sameh Khamis 4298fb0442 test: stabilize Radio HUD measurements and satellite fixtures 2026-09-13 00:43:08 -07:00
Sameh Khamis 1a7e4d51d9 style: format camera panel component modules 2026-09-13 00:38:01 -07:00
Sameh Khamis f45f399c31 refactor: isolate camera panel controls and presentation 2026-09-13 00:38:01 -07:00
Sameh Khamis bd14d6c301 Merge pull request #398 from bilawalsidhu/refactor/radio-controls
refactor: isolate Radio controls and tuner presentation
2026-09-13 00:28:52 -07:00
Sameh Khamis a9e7cb6701 test: measure Cockpit layout after rendered frames 2026-09-13 00:21:51 -07:00
Sameh Khamis 457fe5451c test: follow hold-Space and cancellable tuner keyboard semantics 2026-09-13 00:05:16 -07:00
Sameh Khamis a5af1cbfa3 fix: restore independent UI observer and subscription teardown 2026-09-12 23:53:10 -07:00
Sameh Khamis b119aef0ad test: settle Radio render frames before native picking 2026-09-12 23:49:26 -07:00
Sameh Khamis 11aa90c7bb test: isolate Radio gestures and wait for contained reveal 2026-09-12 23:40:51 -07:00
Sameh Khamis 53c510fe9f fix: keep disposed Radio disclosures inert 2026-09-12 23:40:51 -07:00
Sameh Khamis 641a2c67e2 test: align Radio browser rendering with platform QA defaults 2026-09-12 23:30:23 -07:00
Sameh Khamis 02e90ca698 test: keep Radio invariants independent of formatting 2026-09-12 23:25:09 -07:00
Sameh Khamis bd6cc5abe8 style: format Radio component boundary 2026-09-12 23:25:09 -07:00
Sameh Khamis 7b9aca8597 refactor: isolate Radio controls and tuner presentation 2026-09-12 23:23:20 -07:00
Sameh Khamis 79a0f09087 Merge pull request #396 from bilawalsidhu/refactor/location-controls
refactor: isolate Location controls and search lifecycle
2026-09-12 23:14:57 -07:00
Sameh Khamis 4e33e98377 test: wait for city tiles before Location captures 2026-09-12 23:05:12 -07:00
Sameh Khamis 9bbb437592 test: check searched destination and context on their respective lines 2026-09-12 23:02:16 -07:00
Sameh Khamis 220caf5427 test: follow reset and deferred navigation through Location components 2026-09-12 23:00:02 -07:00
Sameh Khamis 63e5352964 fix: invoke Location animation scheduling through window functions 2026-09-12 22:58:02 -07:00
Sameh Khamis 7485565975 refactor: expose location status parsing with its formatter 2026-09-12 22:56:23 -07:00
Sameh Khamis 82d922ae87 style: format Location component boundary 2026-09-12 22:55:18 -07:00
Sameh Khamis ea77b49482 refactor: isolate Location controls and cancellable search 2026-09-12 22:55:16 -07:00
Sameh Khamis 20c1611354 Merge pull request #395 from bilawalsidhu/refactor/layer-panel
refactor: separate Layers panel presentation and feedback
2026-09-12 22:49:55 -07:00
Sameh Khamis 9f6cde79fd test: check layer counts after lifecycle initialization 2026-09-12 22:43:01 -07:00
Sameh Khamis dec9dd288c style: format layer loading feedback helpers 2026-09-12 22:41:08 -07:00
Sameh Khamis a41f3ec3cd refactor: expose pure layer loading feedback helpers 2026-09-12 22:41:07 -07:00
Sameh Khamis 4d764a82db test: exercise native clear action through layer transaction 2026-09-12 22:38:04 -07:00
Sameh Khamis 648041e040 fix: keep clear control alive when map controls are initialized 2026-09-12 22:37:32 -07:00
Sameh Khamis 4a3f53bfcd test: verify Layers panel replacement in the browser 2026-09-12 22:36:48 -07:00
Sameh Khamis e34d619949 style: format Layers panel components 2026-09-12 22:36:06 -07:00
Sameh Khamis 07cf1695ff refactor: separate Layers panel presentation from lifecycle actions 2026-09-12 22:36:04 -07:00
Sameh Khamis 31f1923633 Merge pull request #394 from bilawalsidhu/refactor/map-source-controls
refactor: isolate Map Source controls
2026-09-12 22:31:36 -07:00
Sameh Khamis 8b21bd8ac5 test: wait for settled map imagery in control captures 2026-09-12 22:26:29 -07:00
Sameh Khamis da7496b7eb style: format Map Source component boundary 2026-09-12 22:25:05 -07:00
Sameh Khamis 72d531b18f refactor: isolate Map Source controls and selection lifecycle 2026-09-12 22:25:04 -07:00
Sameh Khamis 78199c2ab3 Merge pull request #393 from bilawalsidhu/refactor/visual-effects
refactor(ui): separate visual effects from Display controls
2026-09-12 22:04:43 -07:00
Sameh Khamis 12f7e78d83 style: format the visual effects package boundary 2026-09-12 21:56:03 -07:00
Sameh Khamis 1bbb6d9c70 style: format visual effects and shader definitions 2026-09-12 21:55:18 -07:00
Sameh Khamis c30f7cc3be refactor(ui): separate visual effects and animation ownership 2026-09-12 21:55:18 -07:00
Sameh Khamis 5f60d86bc9 Merge pull request #391 from bilawalsidhu/refactor/display-controls
refactor(ui): give Display controls an explicit listener lifecycle
2026-09-12 21:34:01 -07:00
Sameh Khamis b99669d689 style: format Display controls and browser acceptance checks 2026-09-12 21:25:05 -07:00
Sameh Khamis ffc65dd844 refactor(ui): own Display control subscriptions and cleanup 2026-09-12 21:25:04 -07:00
Sameh Khamis 795ed68192 Merge pull request #390 from bilawalsidhu/refactor/visual-input
refactor(ui): extract application shortcuts and parameter controls
2026-09-12 20:54:09 -07:00
Sameh Khamis 2820b2ab8c test(ui): measure held Space with the browser timer clock 2026-09-12 20:48:21 -07:00
Sameh Khamis d7623a148f test(ui): isolate Cockpit scenarios from satellite feed outages 2026-09-12 20:42:54 -07:00
Sameh Khamis 2001df04e2 test(ui): show parameter controls in the narrow viewport capture 2026-09-12 20:41:09 -07:00
Sameh Khamis c9be877f9c style(ui): format shortcut and parameter components 2026-09-12 20:39:47 -07:00
Sameh Khamis 7a5f615d55 refactor(ui): extract application shortcuts and parameter controls 2026-09-12 20:38:16 -07:00
Sameh Khamis 5fa8db1686 Merge pull request #389 from bilawalsidhu/refactor/panel-placement
refactor(ui): extract adaptive panel rail layout
2026-09-12 20:07:17 -07:00
Sameh Khamis 5ca085e709 test(ui): await responsive rail transitions before focus checks 2026-09-12 20:01:13 -07:00
Sameh Khamis e13a680b53 style(ui): format panel rail modules and retain source invariants 2026-09-12 19:55:02 -07:00
Sameh Khamis bf77ae61b8 refactor(ui): extract adaptive panel rail layout 2026-09-12 19:53:17 -07:00
Sameh Khamis 83df873879 Merge pull request #387: share surface keyboard lifecycle
Extract shared Tab, Escape and return-focus handling with explicit listener ownership. Preserve screen policy and validate unit, mutation, browser, tracking and CI checks.
2026-09-12 18:54:22 -07:00
Sameh Khamis bd6ba07878 style(ui): format surface keyboard component and tests 2026-09-12 18:45:30 -07:00
Sameh Khamis e1ffb9c572 refactor(ui): share surface keyboard lifecycle 2026-09-12 18:45:30 -07:00
Sameh Khamis d5b09efa74 Merge pull request #385: extract panel disclosure controls
Share panel keyboard and hover lifecycle with explicit listener and timer cleanup.
2026-09-12 17:44:42 -07:00
Sameh Khamis f35468040e refactor(ui): extract panel disclosure lifecycle controls 2026-09-12 17:38:34 -07:00
Sameh Khamis 29653cb3d8 Merge pull request #367: add issue reporting forms
Collect reproducible reports and allow unavailable diagnostics during installation failures.
2026-09-12 17:20:20 -07:00
Sameh Khamis b892893308 Merge remote-tracking branch 'origin/main' into review/bilawal-issue-forms-367 2026-09-12 17:17:27 -07:00
Sameh Khamis d0aa6a51a5 Merge pull request #371: correct military feed and loading status
Preserve cached observation times and show installation guidance without false load failures.
2026-09-12 17:16:52 -07:00
Sameh Khamis dae5f45426 fix(status): preserve cached aircraft age and independent refresh errors 2026-09-12 17:12:34 -07:00
Sameh Khamis 33ad0de4c6 docs(issues): allow diagnostics unavailable during installation failures 2026-09-12 17:10:00 -07:00
Sameh Khamis dcd20dde10 Merge commit 'f9b939eeeff2e83f43b8216fc474b33e11d13d4d' into review/bilawal-issue-forms-367 2026-09-12 17:07:51 -07:00
Sameh Khamis 8eb6d09105 Merge commit '1a9cfb180914fc9c2b06088a836b45dbf27254d5' into review/bilawal-status-371 2026-09-12 17:00:35 -07:00
Sameh Khamis dfb5f2cfef Merge pull request #383: harden Places and CCTV providers
Harden Places input and CCTV provider downloads
2026-09-12 16:50:11 -07:00
Sameh Khamis 24d2582516 fix(cctv): release snapshot readers and verify provider error paths 2026-09-12 16:37:04 -07:00
Bilawal Sidhu 3d956177ab fix(providers): validate before limiting and tighten capped image reads
Apply shared Places validation, owned image chunks, and response-header deadline tests to the provider modules.
2026-09-12 16:35:11 -07:00
Ethan Stoner 59a20e2d18 fix(cctv): cap buffered snapshot downloads
Adapt the streamed snapshot size cap contributed in public PR #82 to the CCTV media module.
2026-09-12 16:34:51 -07:00
Prajwal Raj c78ea4194c fix: bound CCTV media upstream connection time
Adapt the response-header timeout contributed in public PR #53 to the CCTV provider.
2026-09-12 16:33:51 -07:00
Prajwal Raj 3b19af4076 fix: reject out-of-range coordinates in Google text search
Adapt the coordinate bounds check contributed in public PR #46 to the Places provider.
2026-09-12 16:33:51 -07:00
Sameh Khamis 8bcf1460fc Merge pull request #381: cancel searches on control disposal
Complete the location lookup teardown wiring. Browser disposal and all 108 tracking checks pass.
2026-09-12 15:53:44 -07:00
Sameh Khamis 48a2f06fa2 test: retain existing teardown assertions for cleanup follow-up 2026-09-12 15:49:25 -07:00
Sameh Khamis 3263fe6ff1 fix(search): cancel active lookup when controls are disposed 2026-09-12 15:48:47 -07:00
Sameh Khamis eec4dd5cdb Merge pull request #166: keyless place search through providers
Preserve the original Photon geocoding and place-matching contribution, with explicit service composition, cancellation and outage-cache fixes.
2026-09-12 15:41:25 -07:00
Sameh Khamis 57d7f9fc23 feat(search): compose Google and Photon through an explicit provider service 2026-09-12 15:38:43 -07:00
Bilawal Sidhu 1a9cfb1809 fix(status): stop false LOAD FAILED from a relayed adsb.lol 429 and the installations zoom prompt
Two independent triggers turned the global status chip red while every layer
was still populated (reproduced at JFK in Cockpit; timeline in the issue).

adsb.lol proxy: a non-OK upstream status was relayed verbatim even with a
12 s-old cache in hand, and the browser layer answers a 429 with a 45 s
cooldown in which every refresh reports "adsb.lol rate limited" without a
network call. The proxy now serves its cached body as STALE on a 429/5xx and
backs off from upstream for Retry-After (bounded), mirroring the OpenSky
proxy. A failure with nothing cached is still relayed.

Mapped installations: the "zoom in" guidance was stored in `error`, so the
manager reported a refresh failure and the chip a LOAD FAILED whenever the
view could not be bounded (Cockpit looks at the horizon). The status is now
set without an error string; the row reads the prompt from `statusMessage`,
and the chip ignores `error` for guidance statuses, matching the carve-out
layerFeedState already has.
2026-09-12 17:19:34 -05:00
Sameh Khamis 1194afe468 Merge pull request #180: harden GBFS upstream requests
Preserve the original GBFS redirect and streaming-cap contribution, adapted to the provider modules with full-body deadlines and response cleanup.
2026-09-12 15:18:21 -07:00
Sameh Khamis 4fae7fb278 fix(gbfs): adapt redirect hardening to provider modules and bound body reads 2026-09-12 15:13:14 -07:00
DESTAQandSameh Khamis db49edf363 fix(terrain): keep cold height requests within the browser deadline (#348)
Use 64-point browser and upstream terrain batches to reduce cold-cache timeouts. Preserve successful chunk results, distinguish absent readings from malformed responses, and reject fabricated zero heights.

Original terrain resilience contribution by ItzDESTAQ, with browser batching, validation and regression follow-up by Sameh Khamis.

Validated with unit/build/format/boundary checks, 108 tracking checks, 5 terrain-outage checks, controlled slow-upstream comparison and live browser checks.

Co-authored-by: ItzDESTAQ <[email protected]>
Co-authored-by: Sameh Khamis <[email protected]>
2026-09-12 14:04:08 -07:00
Sameh Khamis 4e99f40488 Merge pull request #373 from bilawalsidhu/refactor/local-service-modules
refactor: split remaining local services into focused modules
2026-09-12 13:12:44 -07:00
Sameh Khamis 3f642f5079 docs: describe focused local service ownership 2026-09-12 13:04:16 -07:00
Sameh Khamis 5223b6df5b style: format extracted local services 2026-09-12 13:01:49 -07:00
Sameh Khamis 233285fe12 test: retain radio schema and voice mutation checks after formatting 2026-09-12 13:01:49 -07:00
Sameh Khamis a64a622b6d test: pin voice schemas independently of source formatting 2026-09-12 13:00:43 -07:00
Sameh Khamis cd92855f88 refactor: isolate standalone key setup and service composition 2026-09-12 12:59:39 -07:00
Sameh Khamis 638902296a refactor: separate local voice handlers and instructions 2026-09-12 12:59:39 -07:00
Sameh Khamis 318d37ad8d refactor: separate regional briefing and weather sources 2026-09-12 12:59:39 -07:00
Sameh Khamis 213edd80ce refactor: separate Overpass and installation services 2026-09-12 12:59:39 -07:00
Sameh Khamis 781613fbb8 Merge pull request #372 from bilawalsidhu/fix/preview-provider-serving
fix: restore data providers in local build preview
2026-09-12 12:52:49 -07:00
Sameh Khamis 280fc00a51 fix: return JSON errors for unmatched local API routes
Test development and built-preview route registration, preserve development-only credential editing, and document local preview configuration.
2026-09-12 12:43:54 -07:00
Jejugo a0f07b752f fix: register data providers for local preview
Adapt the preview middleware contribution from PR #324 to the extracted provider modules, including the existing OpenSky route.
2026-09-12 12:43:54 -07:00
Sameh Khamis 276bb84904 Merge pull request #369 from bilawalsidhu/refactor/cctv-radio-providers
refactor: separate CCTV media and radio directory providers
2026-09-12 12:13:59 -07:00
Sameh Khamis c69416ff6b style: adopt formatting for CCTV and radio provider modules 2026-09-12 12:09:04 -07:00
Sameh Khamis 79b4929b88 refactor: extract CCTV media and radio directory providers 2026-09-12 12:09:02 -07:00
Sameh Khamis fada0c661f Merge pull request #368 from bilawalsidhu/fix/simplify-google-key-setup
Simplify Google Maps setup in POWER UP
2026-09-12 11:40:15 -07:00
Sameh Khamis c6462be066 fix: simplify Google Maps provider setup 2026-09-12 11:36:23 -07:00
Bilawal Sidhu f9b939eeef chore(issues): add issue forms and disable blank issues
Bug reports now require reproduction steps, environment, the doctor
report, and logs. Feature requests require a data source and license.
Blank issues are disabled; security reports are routed to private
vulnerability reporting via the contact links.
2026-09-12 13:28:59 -05:00
Sameh Khamis aacfa06a31 Merge pull request #331 from bilawalsidhu/refactor/environment-provider-modules
refactor: separate terrain traffic fire and bike-share providers
2026-09-11 22:08:00 -07:00
Sameh Khamis eadeac6c10 test: follow extracted FIRMS aggregation code 2026-09-11 22:01:12 -07:00
Sameh Khamis a2d6461b52 style: format terrain traffic fire and bike-share modules 2026-09-11 21:57:51 -07:00
Sameh Khamis f4054469d5 refactor: split terrain traffic fire and bike-share providers 2026-09-11 21:57:50 -07:00
Sameh Khamis 3ec3fe6822 Merge pull request #330 from bilawalsidhu/refactor/space-provider-modules
refactor: separate satellite and launch-feed providers
2026-09-11 21:29:35 -07:00
Sameh Khamis 290afd808d style: format satellite and launch provider modules 2026-09-11 21:22:41 -07:00
Sameh Khamis 9a4b98700e refactor: split satellite and launch provider modules 2026-09-11 21:22:15 -07:00
Sameh Khamis bbabba89bf Merge pull request #328 from bilawalsidhu/fix/landmark-annotation-identity
fix: preserve landmark identity when resolving annotation outlines
2026-09-11 21:18:11 -07:00
Sameh Khamis 38b591863c fix: preserve landmark identity when resolving annotation outlines 2026-09-11 21:07:32 -07:00
Sameh Khamis f883dfbc1e Merge pull request #323: separate place-search and routing providers
Extract focused Node middleware and portable response projections; preserve standalone behavior.
2026-09-11 20:09:26 -07:00
Sameh Khamis 7e6d89d8f5 style: format extracted place and route modules 2026-09-11 19:57:22 -07:00
Sameh Khamis 2793d7a8df refactor: extract place-search and routing provider modules 2026-09-11 19:56:48 -07:00
Sameh Khamis bdfd02ba93 Merge pull request #322: split aircraft and vessel server providers 2026-09-11 19:13:21 -07:00
Sameh Khamis bac45815d1 docs: keep media response documentation with its implementation 2026-09-11 19:09:07 -07:00
Sameh Khamis 2e3cb6270a test: keep provider fixtures independent of calendar time 2026-09-11 19:08:02 -07:00
Sameh Khamis f9a85528fe style: format extracted live provider modules 2026-09-11 19:06:10 -07:00
Sameh Khamis 0e221441b4 refactor: separate aircraft and vessel server providers 2026-09-11 19:06:09 -07:00
Sameh Khamis 149517da81 Merge pull request #321: add main CI badge to README 2026-09-11 18:40:07 -07:00
Sameh Khamis 69bb834bac docs: add main CI badge to README 2026-09-11 18:36:52 -07:00
Sameh Khamis 350896a900 Merge pull request #320: standalone Vite and provider boundaries
Preserve separate structural and mechanical-formatting commits.
2026-09-11 18:13:01 -07:00
Sameh Khamis fb67024f43 style: format standalone configuration and build boundaries 2026-09-11 18:08:26 -07:00
Sameh Khamis 8de1018c21 refactor: separate standalone Vite wiring and provider middleware 2026-09-11 18:08:01 -07:00
Sameh Khamis 493acfc6aa Merge pull request #319: application lifecycle and standalone startup
Preserve separate application extraction and mechanical-formatting commits.
2026-09-11 17:14:11 -07:00
Sameh Khamis 8666705e12 style: adopt application modules into scoped formatting 2026-09-11 17:03:35 -07:00
Sameh Khamis 4d37760314 refactor: extract application construction and lifecycle 2026-09-11 17:02:54 -07:00
Sameh Khamis 092f28e301 Merge pull request #227: scoped formatting and package boundaries
Preserve the contributor commits and adopt scoped formatting with infrastructure export checks.
2026-09-11 16:17:21 -07:00
Sameh Khamis f70b5a3631 style: format the reusable infrastructure component 2026-09-11 16:07:53 -07:00
Sameh Khamis 04f4d54bfe test: allow line wrapping in the stem buffer regression pin 2026-09-11 16:07:53 -07:00
Sameh Khamis 76ddc6a9ef chore: enforce scoped formatting and browser package boundaries 2026-09-11 16:06:35 -07:00
Sameh Khamis ef69f01cd8 Merge formatter contribution with current main 2026-09-11 16:02:38 -07:00
Tom-NeverwinterandSameh Khamis 205c58efbd fix: separate Google browser and server keys (#110)
Use a separate optional server key for Places and Street View, with consistent local and Pinokio configuration, server-first CLI selection, and credential isolation checks.

Original contribution by Tom-Neverwinter; reviewed and completed by Sameh Khamis.

Co-authored-by: Tom-Neverwinter <[email protected]>
Co-authored-by: Sameh Khamis <[email protected]>
2026-09-11 15:08:23 -07:00
Sameh Khamis e9b6214ec4 Merge pull request #313: finish browser QA renderer portability
Complete Tom-Neverwinter's deferred browser QA portability fixes, with renderer evidence, attribution keyboard and 2D floor-hold setup corrections.
2026-09-11 15:04:57 -07:00
Sameh Khamis a8eae34787 fix(qa): select the billboard mode measured by floor-hold checks 2026-09-11 14:54:30 -07:00
Sameh Khamis 67504cd871 fix(qa): align renderer evidence and attribution keyboard assertions 2026-09-11 14:50:57 -07:00
Tom-Neverwinter 01dcecf6e9 fix(qa): choose portable browser renderers outside macOS 2026-09-11 14:43:13 -07:00
Sameh Khamis 162ac4f2c9 Merge pull request #311 from bilawalsidhu/refactor/infrastructure-factories
Extract reusable datacenter and dam layers
2026-09-11 13:39:54 -07:00
Sameh Khamis ed15c5b38e style: remove inherited trailing whitespace from extracted layer 2026-09-11 13:35:52 -07:00
Sameh Khamis 106a794afa fix: preserve standalone overlay defaults for undefined overrides 2026-09-11 13:32:49 -07:00
Sameh Khamis 4688eca9af test: verify infrastructure consumer asset paths and imports 2026-09-11 13:28:55 -07:00
Sameh Khamis 142bf583f8 refactor: export infrastructure layers with explicit application callbacks 2026-09-11 13:21:22 -07:00
Sameh Khamis e465197cee fix: dispose pending infrastructure loads and entity contexts 2026-09-11 13:18:59 -07:00
Sameh Khamis a547e99a7b Merge pull request #302: Declutter infrastructure markers and correct close-up terrain placement
Preserve the infrastructure implementation and separate terrain and idle-render corrections with their contributor authorship.
2026-09-11 09:10:33 -07:00
Sameh Khamis 412ee211b5 fix(infrastructure): follow refined terrain after initial sampling 2026-09-11 09:05:41 -07:00
Sameh Khamis b5568de9fa fix(infrastructure): stop idle republishing and respect settled terrain
Skip unchanged overlay cohorts while detecting moved positions and lifecycle changes. Defer ground sampling until visible terrain settles, retain the loaded surface as a floor, and preserve roof heights and bounded retries. Keep regression and idle-render gates intact.
2026-09-11 08:59:32 -07:00
Bilawal Sidhu 6dd00d1e76 fix(infrastructure): declutter stems and correct close-up sizing 2026-09-11 08:59:32 -07:00
AL Zami Rohan 3f77c690f3 Merge branch 'main' into chore/prettier 2026-09-11 15:22:24 +06:00
Sameh Khamis c7ef01827d Merge pull request #291: preserve keyboard focus and Space activation
Preserve the contributor commits for keyboard accessibility and focused browser QA.
2026-09-10 23:54:25 -07:00
Sameh Khamis 1a74e56bc0 test: isolate Cockpit readiness and verify keyboard focus in the browser
Use a fresh page for first-fetch assertions and wait for rendered camera state instead of fixed timing. Check connected focus retention during row reorder, departed-contact continuation, and attribution keyboard ownership in Cockpit.
2026-09-10 23:48:24 -07:00
Tom-Neverwinter 77575b9dc6 fix(qa): use a portable Cockpit browser backend outside macOS 2026-09-10 23:48:24 -07:00
manjunath22466 5228ab9e40 fix: preserve keyboard focus and separate Space activation from voice
Keep focus visible across controls and stable during Contacts, Cockpit and mission roster updates. Preserve native short Space activation, and claim push-to-talk only after a 500 ms hold. Add panel Escape handling and keyboard-accessible attribution controls.

Retains the Map Source foundation contributed in https://github.com/bilawalsidhu/gods-eye-view/pull/171.
2026-09-10 23:48:12 -07:00
Sameh Khamis c82fdd7478 Merge pull request #290: update vulnerable dependencies and browser QA
Includes the reviewed dependency updates and Puppeteer compatibility changes, verified with the accepted security fixes.
2026-09-10 23:27:30 -07:00
Sameh Khamis 5a8738c11d Merge main security fixes before dependency verification 2026-09-10 23:20:24 -07:00
Sameh Khamis e9fd272ade Merge pull request #289: consolidate CCTV startup and sanitize proxy failures
Preserve contributor authorship and the reviewed launcher, proxy, and regression fixes.
2026-09-10 23:19:42 -07:00
Sameh Khamis f94bdb1d77 fix: update vulnerable dependencies and adapt browser QA to Puppeteer 25 2026-09-10 23:08:26 -07:00
Sameh Khamis f47cd792d1 fix: share CCTV startup and keep proxy diagnostics bounded 2026-09-10 23:01:51 -07:00
Tom-Neverwinter 8fc86e0780 fix: sanitize proxy failures and default CCTV launcher to loopback 2026-09-10 22:54:50 -07:00
Sameh Khamis a458d27067 Merge pull request #171: preserve Map Source keyboard focus
Preserve the selected map source through delayed keyboard opening and cancel interrupted focus requests.

Retain Tom-Neverwinter's original focus repair and Manjunath's separately authored selection/cancellation refinements, with maintainer integration and verification commits.
2026-09-10 22:16:41 -07:00
Sameh Khamis 231d339e92 test(a11y): cover delayed and interrupted Map Source focus
Keep the Esri settlement assertion exact, verify selected-source focus and Tab departure in Chromium, cancel pending work during disposal, and document the focused behavior.
2026-09-10 22:12:48 -07:00
manjunath22466 60812df34c fix(a11y): keep Map Source focus on the selected source and cancel stale requests
Build on Tom-Neverwinter’s Map Source focus retry. Prefer the selected tile, and revoke delayed focus when the user moves focus, clicks, or closes the tray.
2026-09-10 22:08:49 -07:00
Sameh Khamis 5dfef8e891 Merge main and narrow the change to Map Source focus and QA 2026-09-10 22:07:41 -07:00
Sameh Khamis 6fb8cc37d5 Merge pull request #216 from kk376/fix/hud-input-a11y-labels
fix(a11y): add accessible names to hud sliders and inputs
2026-09-10 21:37:53 -07:00
Sameh Khamis 3d526f208d Merge main and narrow accessible input names to useful controls 2026-09-10 21:33:45 -07:00
mwilliamsGH e7cb08f5f6 fix(firms): record source success after aggregation (#181)
Record FIRMS source success after appending its rows so aggregation failures do not produce contradictory source status. Cover large feeds, filtering, ordering and source failures with regression tests.
2026-09-10 21:13:46 -07:00
Malek 87a92c8bc4 fix(radio): resolve common country exonyms Intl.DisplayNames omits (#232)
Recognize common country names and exonyms in Radio country requests and station filtering. Keep ambiguous country names rejected.
2026-09-10 21:01:39 -07:00
Sameh Khamis 024738a8d2 Merge pull request #197 from onionviolet/codex/upstream-payload-truth
fix(launches): preserve unknown payload details
2026-09-10 20:34:35 -07:00
onionviolet a4b6dbb41f fix(launches): preserve unknown payload details 2026-09-10 23:29:55 -04:00
Sameh Khamis 5067abfe9b Merge pull request #198 from onionviolet/codex/upstream-earthquake-snapshot
fix(earthquakes): validate snapshots before replacing entities
2026-09-10 20:00:24 -07:00
Sameh Khamis ac3926c7db fix(earthquakes): reject malformed feature properties 2026-09-10 19:56:01 -07:00
Tom-NeverwinterandClaude Opus 5 0584a1d71d fix: sanitize proxy error payloads, loopback dev-cctv, drop doc drift
Four small, independently-verified issues from the audit backlog:

- #262/#263: the celestrak, terrain-heights, adsbdb and launch-library
  proxies put raw `err.message` / raw upstream error bodies into HTTP
  responses, contradicting the sanitized-error property KNOWN-ISSUES.md
  documents. They now log the real error server-side (matching the GBFS
  and CCTV proxy convention) and return a generic message.
- #265: scripts/dev-cctv.sh defaulted to HOST=0.0.0.0 while dev-fresh.sh
  and dev-secure.sh default to loopback. Now loopback, opt in with
  HOST=0.0.0.0.
- #274: naturalEarthRegions.js's internal haversineKm took (lon, lat,
  lon, lat) while every other haversineKm in src/ takes (lat, lon, lat,
  lon). Same name, same signature shape, opposite order — a silent
  wrong-distance landmine. Reordered to match; the one call site was
  updated, so behavior is unchanged.
- #270: removed CCTV_AUTO_CALIBRATE and CCTV_DRAPE_MESH from
  .env.example; no code anywhere reads them.

Not done: deduplicating the ~10 haversine implementations into one
shared utility. That is issue #188's scope and a far larger diff;
removing the argument-order landmine is what makes the current state
safe.

Verified: npm test (2705 pass / 0 fail), npm run build green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3eGYgDYBo2BCUeDGwGvrp
2026-09-10 15:24:57 -05:00
Tom-NeverwinterandClaude Opus 5 ea344c952f fix(qa): don't pass the macOS-only Metal ANGLE backend on other platforms
Six QA harnesses launched Chrome with `--use-angle=metal` unconditionally
(or defaulted to it). Metal is a macOS-only ANGLE backend: on Windows and
Linux it makes WebGL initialization fail outright, so CesiumWidget never
constructs, the app never boots, and the harness dies at its
`waitForFunction` boot wait — before running a single assertion:

    Error constructing CesiumWidget.
    RuntimeError: The browser supports WebGL, but initialization failed.
    TimeoutError: Waiting failed: 60000ms exceeded

Affected: qa-map-source-tray, qa-cockpit-utility, qa-view-target-prewarm,
qa-firstrun (unconditional), qa-cockpit-plates (GPU branch taken off-Mac
too) and qa-floor-hold (`--angle` defaulted to metal everywhere).

Fixed by adopting the platform check qa-flyroute-cinema.mjs already uses:
real GPU via Metal on darwin, the portable swiftshader rasterizer
elsewhere — which is what the other ~20 QA scripts already pass. No
behavior change on macOS.

Verified on Windows 11: `npm run qa:map-source-tray` went from 0
assertions (boot timeout) to all 20 checks running.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3eGYgDYBo2BCUeDGwGvrp
2026-09-10 15:24:57 -05:00
Tom-NeverwinterandClaude Opus 5 6c49c2aa22 fix(qa): de-flake the Esri fallback check's fixed 50ms DOM settle
The last fixed sleep in this harness: after the controller commits
`activeId = 'osm'`, the fallback callback re-syncs the chips and Cesium
drops the "Powered by Esri" credit on a later render frame again. A flat
50ms covered that most runs and not a slow one — observed failing once in
12 runs on Windows with `creditVisible: true` after the fallback.

Same root cause and same remedy as the tray timers above: poll the
observable truth (credit gone AND the pressed chip is osm), bounded at 3s,
instead of guessing a duration.

0 failures in 15 consecutive runs after this, against 1-in-12 before.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3eGYgDYBo2BCUeDGwGvrp
2026-09-10 15:10:05 -05:00
Tom-NeverwinterandClaude Opus 5 56d52e284f Focus the Map Source tray when it is actually visible, not 240ms later (#54)
`qa:map-source-tray` failed on a clean checkout at "Enter opens the tray and
hands focus to a Map Source tile". The gate was right: this is a real
keyboard-accessibility bug, not a flaky assertion.

Opening the tray schedules the focus hand-off to a Map Source tile 240ms
later (scheduleMapSourceFocus). The tray itself fades in behind a 180ms
`visibility` transition on .dock-popover-content, and a chip inside it cannot
take focus until that transition lands. Those two timers race. When the
machine is slow enough that the fade has not finished at the 240ms mark,
`chip.focus()` is called on a `visibility: hidden` element — which is a
SILENT no-op — and nothing retries. The tray sits open with keyboard focus
stranded on the disclosure, unreachable.

Instrumenting HTMLElement.prototype.focus during a failing run showed exactly
that: focus() called once on the photoreal chip, the chip connected and not
disabled, computed visibility `hidden`, activeElement unchanged.

Fix the hand-off rather than the delay:
- focusMapSource now reports whether focus actually landed (activeElement ===
  chip) instead of assuming .focus() worked.
- scheduleMapSourceFocus retries on a 30ms cadence until it lands, bounded to
  ~720ms past the first attempt so a permanently hidden tray cannot spin.

Also stop the QA gate racing the same timers. Its keyboard checks slept a
fixed 300ms and asserted focus had already moved; they now wait on controller
truth (aria-expanded plus where focus landed), the way the Bing-switch check
already did. That replaces four fixed sleeps, not just the long-Enter-hold
one the issue named. The wait only settles state — the check() below each one
is still the assertion, so a swallowed timeout surfaces as that check failing
with real values rather than an opaque puppeteer error.

Verified against a preview build: `qa:map-source-tray` goes from 3 failures
to 20/20 passing, twice in a row. Unit suite unchanged (2674 passing).

The #44 half of the original work is intentionally dropped: upstream's
track-regression.mjs already fixed the ground-3d sampleHeight floor, and more
thoroughly — it pins no absolute call count at all and adds a guard against a
timed-out driver falsely looking flat.

Closes #54.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q6VRsPswjNtZfv4jzgkCUf
2026-09-10 14:46:49 -05:00
Devin Lucas db7ff73bbf Merge branch 'main' into fix/gbfs-proxy-redirects-streaming-cap 2026-09-09 15:55:11 -06:00
RohanDaCoder fd01f22b88 chore: add prettier with repo code style 2026-09-09 20:14:40 +06:00
Kushagra Kumar 9e92ab6f9b fix(a11y): add accessible names to hud sliders and inputs
Add explicit aria-label attributes to unlabelled interactive controls in index.html and dynamic parameter sliders in src/ui.js, fulfilling WCAG 2.1 Success Criterion 4.1.2 (Name, Role, Value).

- Add aria-label to #scope-feather-slider, #bloom-intensity-slider, #sharpen-intensity-slider, #location-search, #scene-import-file, and first-run suppression checkbox.
- Set aria-label to uniform label on dynamic style parameter sliders in ui.js.
- Add unit test suite (src/hudA11y.test.mjs) verifying accessible names across all inputs in index.html and dynamic slider construction.

Signed-off-by: Kushagra Kumar <[email protected]>
2026-09-09 15:43:58 +05:30
onionviolet c390724bc0 fix(earthquakes): validate snapshots before replacing entities 2026-09-08 17:46:01 -04:00
Bilawal Sidhu 759652207f fix: improve mapped installations and map-source guidance (#183)
Improve mapped installation recovery, deselection, retry feedback, and visual-style and map-source guidance.
2026-09-05 03:30:55 -05:00
daikaginza e21c174f76 feat(search): finish the keyless fallback at every geocode call site
The keyless fallback landed on the LOCATION search box only. Two other places
forward-geocode a name and neither had it: the annotation resolver returned
null, so an annotation silently failed to anchor, and the Radio layer THREW,
which surfaced as a failed voice turn rather than as a station it could not
place. src/keylessGeocoder.test.mjs now COUNTS the call sites instead of
checking them file by file, so a second seam added to a file that already has
a fallback fails the suite too.

The annotation path carries the feature's own name out of Photon. Footprint
scoring matches OSM on that canonical name rather than on the user's words, so
incidental locality tokens cannot win: "the monument near the Texas Capitol,
Austin" scored on the raw utterance picks a downtown building that shares only
"Texas", "Capitol" and "Austin".

Photon's country is deliberately NOT re-exported. It comes back in the
feature's own language — "Việt Nam", "日本", "Россия" — and
normalizeRadioCountryInput fails CLOSED on a name it cannot map, so forwarding
it would filter the station list to nothing for exactly the places the lookup
had just resolved. The composed label still names the country. A keyless
lookup must not apply a filter the keyed one never applies.

A not-found is now remembered only when every source consulted returned a
verdict. Photon not replying is a network blip, not "no such place": memoizing
it kept the query answering not-found for the rest of the session on a network
that had since recovered — the same conflation the Overpass disk cache had to
be repaired for. geocodeMissIsDefinitive states the rule once and both entry
points use it, and the adapter's own memo follows it as well.

Reverse geocoding stays out of scope: the street-label reader consumes
Google's ranked address_components, and Photon's /reverse cannot reproduce
that shape.
2026-09-05 09:46:04 +07:00
daikaginza acac1e4842 fix(search): keep proximity bias from choosing a different place
Photon scores proximity against the name, so a viewport bias could outrank
an exact name match by distance alone. Searching "Hue" from Austin returned
Hutto, Texas; "Ha Long" returned Long Branch. Measured against the live
service over twelve Vietnamese place names, three resolved to the wrong
continent.

Google's `bounds` prefers a result inside the box without changing what
qualifies as a result, and that is the semantics this adapter promised to
reproduce. So a biased answer is now accepted only when a candidate leads
with the name searched for, and the query otherwise re-runs unbiased — one
extra request, on exactly the searches that were going to be wrong.

Matching leads rather than contains, which is what separates "Hue" from
"Nguyen Hue Road", and compares unaccented so "Hue" and "Huế" are one name.
On the unbiased pass the head of a "place, region" query is matched first;
the tail only says where to look.

Nearby searches are unaffected: "Sixth Street" and "Barton Springs" over
Austin still take the biased answer in a single request.
2026-09-05 09:46:04 +07:00
daikaginza f16fc2db0d feat(search): fall back to keyless Photon geocoding
Place search was Google-only: searchAndFlyTo threw without a key, and with a
key whose Geocoding API is not enabled it returned nothing. Both cases leave
the LOCATION box and the fly_to_location voice tool unable to resolve a name,
even though every other layer in the app works keyless.

src/keylessGeocoder.js adapts Photon (komoot, over OpenStreetMap) into the
shape the Google path already produces — lat/lng, label, Google-style types,
{southwest,northeast} bounds — so geocodeNavigationMode, regionFramingPlan and
the region-swath cap keep running on unchanged inputs. The translation lives at
the boundary and nowhere else; Google remains primary and its branch is
unchanged when it answers.

Detection is the empty result, not an exception: an unenabled Geocoding API
answers HTTP 200 with REQUEST_DENIED. Places near-view recovery is now
attempted only when a key exists, since Places is a Google service too.

Three translations are load-bearing, and each was found by querying the live
service rather than by reasoning about OSM tagging:

- Google's `bounds` PREFERS results inside the box; Photon's `bbox` FILTERS to
  it. Sent as a bbox, searching "Ho Guom, Ha Noi" while looking at Austin
  returns features: []. Sent as Photon's lat/lon proximity bias it finds the
  lake, and "Sixth Street" still resolves to Austin's over Austin and to
  England's over London.
- Photon's `extent` is [west, north, east, south]. Read as [w,s,e,n] it yields
  an inverted but finite box that frames the camera at nothing.
- A specific OSM key=value rule must beat the coarse `type`. Photon tags a lake
  `water=lake` with `type: 'other'` — unmapped, that framed Hoan Kiem Lake as a
  precise place and put the camera 26 m over the water — and tags a town square
  `type: 'locality'`, which framed Times Square as if it were a city.

searchAndFlyTo no longer throws when no key is present. Both callers already
read a null return as not-found (ui.js renders its miss branch, gevActions
reports ok: false), so the removed throw has no consumer.

Verified end to end through the real search box, with a key Google refuses:
one denied Google geocode, one Photon request, camera from Austin
(30.2700, -97.7400) to 21.0288, 105.8524 — Hoan Kiem Lake to within metres —
and "Ha Noi" framed at province scale. npm test 2604/2604; npm run build
clean; npm run test:track 97/3, the same three failures unmodified main
produces on this machine.
2026-09-05 09:46:04 +07:00
Bilawal Sidhu 3b6b3f1007 Merge README Pinokio setup refresh (#182)
Refresh Pinokio installation guidance, launch recognition, and README copy. Documentation only; existing media and runtime behavior are unchanged.
2026-09-04 21:22:04 -05:00
Bilawal Sidhu b6d0518960 docs: refresh Pinokio setup and README copy 2026-09-04 21:17:51 -05:00
dlucas489 db35b4978b fix: gbfs - refuse upstream redirects and enforce cap while streaming. extract network step into fetch so test can stand in. Pass redirect 'manual,' reject and 3xx w/ a 502. Log target host serverside. Replace read then measure cap w/ readResponseTextCapped. Cancels stream if over 5mb. Closes #30 and #31 2026-09-04 15:31:06 -07:00
Bilawal Sidhu 25838e056a Merge pull request #173: Overpass mirror and cache recovery
Recover across existing Overpass mirrors, reject cached refusals, and serve consistent last-good data to coalesced callers. Preserve the original contribution from PR #165.
2026-09-03 21:47:45 -05:00
Bilawal Sidhu 7ffa421932 fix(overpass): reject legacy cached refusals and share stale recovery 2026-09-03 21:39:33 -05:00
daikaginza 973b4e147f fix(overpass): rotate mirrors on any refusal, and never cache one as data
The proxy lists four mirrors and rotated on 5xx alone, so a 4xx ended the
fan-out. Measured against the live services: overpass-api.de and its lz4 alias
answer 406 to this proxy's User-Agent, while overpass.kumi.systems and
overpass.private.coffee answer 200 to the byte-identical request. Every
Overpass-backed feature — road geometry, annotation outlines, place lookup —
was failing on an Apache error page with two healthy mirrors untried.

The refusal was then cached. The cache guard read `status < 500`, so the error
page was written to memory and to disk, and the serve-stale guard used the same
threshold and declined to replace it. Boundary-class queries hold a month-long
TTL: four of twenty-three cached entries on this machine held that 406, dated
days after the mirror had stopped refusing.

Both decisions now go through one predicate. A payload is data only when it is
a 2xx that is neither rate-limited nor a body-level runtime error, so what may
be cached and what may be replaced by a stale entry cannot drift apart again.

A refusal every mirror agrees on is still reported with the first mirror's
status and body, so a malformed query says what upstream said — after every
mirror has had its chance, not instead of it. fetchOverpassPayload takes
injectable endpoints and fetch so the rotation is covered without a live
mirror; restoring either half of the old behaviour fails the new tests.
2026-09-03 16:54:20 +07:00
Bilawal Sidhu 9dfb5369e8 Merge pull request #160 from bilawalsidhu/docs/maintainers-and-hosted
docs: name maintainers, add CODEOWNERS, announce hosted version
2026-09-02 18:28:59 -05:00
Bilawal Sidhu 10df9c01e2 docs: add maintainers and announce hosted version 2026-09-02 18:24:03 -05:00
Bilawal Sidhu 65bc522f49 Merge pull request #153 from bilawalsidhu/release/v0.1.1
release: v0.1.1 — installation and live-data fixes
2026-09-01 14:41:55 -05:00
Bilawal Sidhu ae66e9d299 chore(release): prepare v0.1.1
Promote the current reliability fixes into a dated patch release and align the package metadata.
2026-09-01 14:38:52 -05:00
Bilawal Sidhu d399dfc92d docs: tighten README opening
Simplify the keyless Quick Start pitch and clarify which data is live, regularly refreshed, simulated, or estimated.
2026-09-01 14:38:52 -05:00
Bilawal Sidhu 53a5f01a79 Merge pull request #152 from bilawalsidhu/fix/pinokio-install-marker
fix: recognize completed Pinokio setup
2026-09-01 11:21:54 -05:00
Manjunath 39d33b3f29 fix: repair fresh integration setup detection 2026-09-01 11:17:42 -05:00
Bilawal Sidhu d8f1742783 fix(dev-fresh): keyless launch no longer crashes on stock macOS bash 3.2 (#144)
Expanding the empty KEY_SETUP_EXTERNAL_KEYS array under set -u is a fatal
'unbound variable' on bash 3.2, which macOS ships as /bin/bash — so a keyless
./scripts/dev-fresh.sh died at the provenance-marker line before doing
anything. The ${arr[*]:-} guard keeps 3.2 alive and leaves populated launches
byte-identical (same idiom the script already uses for DEV_UNSET).

Verified with /bin/bash 3.2.57: the extracted block runs clean under set -u
with no keys (empty CSV) and with keys (ordered comma-joined names); the full
launcher boots keyless to a live server, and exported keys still classify as
externally managed in Provider Settings. A behavioral guard now runs the real
block from the script both ways in the dev-fresh test surface, plus a textual
assertion pinning the 3.2-safe idiom.
2026-08-31 19:00:33 -05:00
9db0813daf fix: measure GBFS response cap in bytes, not string length (#45)
* fix: measure GBFS response cap in bytes, not string length

The 5 MB cap compared GBFS_MAX_BODY_BYTES against body.length, which is the
string's UTF-16 code-unit count rather than its byte size. Those match for
ASCII but diverge for multi-byte payloads, so a large non-ASCII response
could slip past the cap. Switched to Buffer.byteLength.

Closes #32

* docs(changelog): move the GBFS byte-cap entry into the current Unreleased section

---------

Co-authored-by: Bilawal Sidhu <[email protected]>
Co-authored-by: Bilawal Sidhu <[email protected]>
2026-08-31 18:55:17 -05:00
6b7bca2913 fix(firms): drop argument spread in fires.push — RangeError silently dropped 2 of 3 VIIRS sources (#93)
* fix(firms): drop spread in fires.push — RangeError silently killed 2 of 3 VIIRS sources

fires.push(...records) passes every record as a function argument. A
world/2 VIIRS pull returns ~131k records for NOAA20 and SNPP, over V8's
~125k argument limit, so both threw RangeError: Maximum call stack size
exceeded. The throw landed after sources.push({ok:true}), so each failed
source was listed twice — once ok:true with its real count, once ok:false
— while its records were dropped entirely. Only NOAA21 (114k, under the
limit) survived, making the layer look healthy at a third of the data.

Global fire count goes 113,996 -> 377,169.

* docs(changelog): record the FIRMS multi-source merge fix

* docs(changelog): move the FIRMS fix into a new Unreleased section above 0.1.0

---------

Co-authored-by: Bilawal Sidhu <[email protected]>
Co-authored-by: Bilawal Sidhu <[email protected]>
2026-08-31 18:50:49 -05:00
Bilawal Sidhu b6da93b8ac release: v0.1.0 — one-click install, keyless boot, in-app Provider Settings (#142)
The first formal release.

- One-click install via Pinokio; keyless boot lands on a live Esri World
  Imagery satellite globe with keyless terrain, with automatic OSM fallback
  and graceful degradation when terrain is unavailable.
- Provider Settings (the POWER UP panel): add, replace, or remove API keys
  inside the app; credential files made owner-only before any secret is
  written; external keys shown read-only; the panel refuses shared or proxied
  servers.
- Keyless capability responses for the optional HUD summary and place search.
- Aircraft-identity voice answers cover operator, type, and route, and say so
  plainly when enrichment is unavailable.
- README rewritten keyless-first; concise 0.1.0 changelog section; CI
  workflow included.

Gates: 2,672 unit tests pass / 0 fail, build clean, tracking regression
108/108, map-source tray QA pass, setup doctor ready.
2026-08-31 17:49:45 -05:00
Ethan Stoner 6d83bb6008 fix(data): keep node:fs out of the browser-built data modules (#83)
naturalEarthRegions.js and neighborhoodPolygons.js each carried an
isNode branch that dynamically imported node:fs to read their bundled JSON
packs, because a plain dynamic JSON import needs an import attribute in
Node. Vite only warns about externalizing node:fs for the browser, so both
warnings survived every production build and the runtime boundary rested on
an environment guard.

Give both loaders the import attribute instead. Vite bundles the JSON as a
module exactly as before (the emitted regions/marine/san-francisco chunks
are byte-identical), Node loads the same files under node:test, and the
isNode split disappears.

Add a source-boundary test so a node: import cannot silently return to a
browser-built module.

Closes #34
2026-08-31 15:25:02 -05:00
Ethan Stoner ac927de71a chore: normalize line endings to LF via .gitattributes (#81) 2026-08-31 15:24:57 -05:00
Bilawal Sidhu 314a0e1c2e Streamline README: AI-assisted setup instructions, walkthrough video, consolidated demo flow 2026-08-27 19:46:09 -05:00
Bilawal Sidhu 880a672b5e Release God's Eye View as open source 2026-08-24 17:02:16 -05:00
Bilawal Sidhu a5aaea7e16 Trusted testing underway; public release by August 24 2026-08-18 14:43:32 -05:00
Bilawal Sidhu 6b8b8afba6 Set release date and refresh README
- Public release set for August 18, 2026, with a running updates log
- New hero GIF and four interface stills replacing the old previews
- Rewrite copy: lead with lineage, then the news, then the product
- Remove superseded hero images
- Add .gitignore for .DS_Store
2026-07-30 22:15:16 -05:00
Bilawal Sidhu 7afb3beefb Prepare public release placeholder 2026-06-22 23:18:38 -05:00
663 changed files with 248381 additions and 0 deletions

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
# 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.
#
# Easiest path: don't edit anything. Run the app and paste keys into the
# in-app Provider Settings panel (the POWER UP chip, bottom-right) — it writes
# this checkout's .env for you (owner-only permissions) and restarts the dev
# server. Under the Pinokio launcher it writes pinokio/ENVIRONMENT instead.
# Keys you supply yourself (shell env, Keychain) are shown as configured
# externally and never touched. This file remains the reference for headless
# and self-hosted setups.
#
# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN are
# injected into the browser bundle by design (they're used client-side) and
# WILL be visible in devtools. Restrict/scope them rather than trying to hide
# them (see SECURITY.md). All other keys below stay server-side.
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
# Without it, a Cesium ion token can still load ion-hosted Google 3D; with
# neither credential, the app starts on keyless Esri World Imagery with OSM
# 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.
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
# process-local guard (in-memory, resets on restart; over-limit requests get a
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
# (plus per-API quotas under APIs & Services -> Quotas).
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
# Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world
# imagery, and Cesium World Terrain. The free Community plan is for eligible
# personal/non-commercial use and has quotas; check current Cesium terms. A
# direct Google key above is still required for GEV place search.
# CLIENT-EXPOSED — use a public assets:read token with URL restrictions.
CESIUM_ION_TOKEN=
# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.
OPENAI_API_KEY=
OPENAI_REALTIME_MODEL=gpt-realtime-2
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
# Model ids and prices are external facts that drift — if OpenAI moves this id,
# override it here rather than editing src/voice/voiceCost.js.
OPENAI_REALTIME_MODEL_MINI=gpt-realtime-2.1-mini
OPENAI_REALTIME_VOICE=marin
OPENAI_REALTIME_REASONING_EFFORT=low
# Keep only a short conversational window. Current map state is fetched live.
OPENAI_REALTIME_CONTEXT_TOKENS=3000
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
# Set a positive integer to cap per-IP spend when self-hosting on a network.
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
# process-local guard (in-memory, resets on restart; over-limit requests get a
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
# Default: oauth — requires client credentials below
# Set to "anon" to skip aircraft auth (rate-limited but works)
OPENSKY_AUTH_MODE=oauth
# Optional: OpenSky OAuth credentials (from opensky-network.org account dashboard)
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
# Optional: higher Launch Library 2 request allowance. Public access works
# without a token.
LL2_API_TOKEN=
# Optional: OpenSky credentials JSON file path (alternative to above)
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
# Optional: dev server config
PORT=4173
# Network binding. Default is localhost — the server is reachable ONLY from
# this machine. This is the safe default: the dev server brokers your
# configured API keys, so anyone who can reach it can spend your quota.
# LAN opt-in: set HOST=0.0.0.0 to share on your local network. WARNING: that
# exposes every key-brokering proxy to everyone on the network — use only on
# networks you trust, and consider the GEV_RATELIMIT_* throttles above.
# HOST=localhost
# HOST=0.0.0.0
# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the
# browser — the client polls the /api/firms proxy). Free key:
# https://firms.modaps.eosdis.nasa.gov/api/map_key/
# Without a key the FIRMS Active Fires layer is empty ("KEY REQUIRED").
# FIRMS_MAP_KEY=
# Optional: Live AIS vessels layer
# Browser fetch path. Keep same-origin so the private stream key stays server-side.
VITE_AIS_LIVE_API_URL=/api/ais-live
# Client render cap. The server can keep a larger AISStream cache, but drawing
# every global vessel at once can overwhelm WebGL on laptops.
VITE_AIS_LIVE_MAX_ROWS=12000
# Active label cap after screen-space clustering. Labels exist for all rendered
# ships, but only clustered winners are shown to keep text readable.
VITE_AIS_LIVE_LABEL_MAX_ROWS=900
# Private AISStream key for the server-side websocket. Do not prefix with VITE_.
AISSTREAM_API_KEY=
# Optional AISStream subscription filters.
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
# default worldwide subscription — a narrow filter above can be legitimately
# quiet, so set this explicitly (sized for your filter) to opt back in.
# 0 disables the silence watch entirely.
# AISSTREAM_SILENCE_TIMEOUT_MS=120000
# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile
# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
# the browser fetches same-origin /api/tomtom/* and never sees the key.
# Keyless fallback: the traffic layer runs its built-in simulation (white dots,
# hardcoded per-road-class speeds) — no key required for the layer to work.
# TOMTOM_API_KEY=
# Optional soft cap on upstream tile fetches per UTC day (default 40000) — a
# configurable application safety ceiling, not a guarantee of staying within
# TomTom's free allowance (currently 200K tile requests/month:
# https://docs.tomtom.com/pricing/). Over the cap the proxy serves cached/stale
# tiles instead of hitting upstream.
# TOMTOM_DAILY_TILE_BUDGET=40000
# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset
# unless you're customizing the camera source pack.
# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON
# CCTV_SOURCES_JSON= # inline source-pack JSON (overrides file)
# CCTV_AUSTIN_ROWS_URL= # override the Austin Open Data catalog URL
# CCTV_AUSTIN_MAX_SOURCES=36 # cap cameras loaded from the Austin catalog
# CCTV_MAX_SOURCES=48 # overall cap on registered cameras
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force Austin-only sources
# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag)
# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag)
+20
View File
@@ -0,0 +1,20 @@
# Check every text file out with LF on every platform.
#
# 44 unit test files read repository source with readFileSync and match it
# against multi-line regexes (src/scenes/director.test.mjs,
# src/cockpitMarkup.test.mjs, src/firstRunExperience.test.mjs, ...). Those
# patterns anchor on \n, so under Git's default core.autocrlf=true on Windows
# the sources check out with CRLF, every anchored pattern misses, and `npm test`
# fails 25 checks on an unmodified clone. Normalizing here keeps the working
# tree byte-identical on macOS, Linux, and Windows.
* text=auto eol=lf
# Shell launchers must stay LF to run at all.
*.sh text eol=lf
# Assets Git must never rewrite.
*.gif binary
*.png binary
*.glb binary
*.wav binary
*.pbf binary
+1
View File
@@ -0,0 +1 @@
* @bilawalsidhu @samehkhamis
+69
View File
@@ -0,0 +1,69 @@
name: Bug report
description: Something is broken, fails to install, or renders wrong.
title: "bug: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for the report. Reproduction steps and relevant errors help us investigate. If installation failed before you could run a command, say which command failed and why. Redact API keys, tokens, and personal information; do not paste your .env file.
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what you saw instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Fresh clone, `npm ci`, `npm run dev`
2. Open http://localhost:4173, click LOCATION, type "London"
3. See "Search failed" toast
validations:
required: true
- type: dropdown
id: install
attributes:
label: How you installed
options:
- Pinokio one-click
- Terminal (git clone + npm)
- Docker
- Other (say what in the description)
validations:
required: true
- type: input
id: env
attributes:
label: OS, Node version, browser
description: Include the versions you can find. Write "unknown" for anything unavailable.
placeholder: "Windows 11, Node 24.14.0, Chrome 151"
validations:
required: true
- type: input
id: commit
attributes:
label: Version or commit
description: Output of `git rev-parse --short HEAD`, or the release tag. If unavailable, say when you installed or last updated.
placeholder: "aacfa06"
validations:
required: true
- type: textarea
id: doctor
attributes:
label: Output of `npm run doctor`
description: Run this in the app directory. It reports which providers are configured without printing key values. If it cannot run, describe the failure instead.
render: text
validations:
required: true
- type: textarea
id: logs
attributes:
label: Server log and browser console
description: Paste the relevant error lines from the terminal or Pinokio log and browser console. If no log is available, explain what prevented it. Redact credentials and personal information.
render: text
validations:
required: true
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/bilawalsidhu/gods-eye-view/security/advisories/new
about: Report anything exploitable privately. Do not open a public issue for it.
- name: Setup and quick start
url: https://github.com/bilawalsidhu/gods-eye-view#readme
about: Install paths (Pinokio, terminal), provider keys, and what runs keyless.
- name: Known issues
url: https://github.com/bilawalsidhu/gods-eye-view/blob/main/docs/KNOWN-ISSUES.md
about: Check here before filing. Your problem may already be tracked with a workaround.
@@ -0,0 +1,39 @@
name: Feature request
description: Propose a new layer, data source, tool, or UI capability.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
The project only ships public data with a license that allows redistribution, and it does not build features for identifying or tracking individuals. Read the "Responsible & Open" section of the README before proposing a data source.
- type: textarea
id: problem
attributes:
label: Problem
description: What can you not do today, and who needs it?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should it do? Keep the first version small.
validations:
required: true
- type: textarea
id: source
attributes:
label: Data source, license, and attribution
description: For anything that shows data, link the feed or API, name its license, and say whether it works without a key. Write "none" for pure UI changes.
validations:
required: true
- type: dropdown
id: contribute
attributes:
label: Will you build it?
options:
- Yes, I plan to open a PR
- No, this is a request for someone else
validations:
required: true
+89
View File
@@ -0,0 +1,89 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: Node ${{ matrix.node }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
node: [24.14.0, 26.x]
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }}
steps:
- name: Check out source
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install locked dependencies
run: npm ci
- 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
- name: Build production bundle
run: npm run build
windows-onboarding:
name: Windows onboarding
runs-on: windows-latest
timeout-minutes: 20
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
GEV_REQUIRE_ALLOCATION_GATE: '0'
steps:
- name: Check out source
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 24.14.0
cache: npm
- 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
- name: Build production bundle
run: npm run build
+14
View File
@@ -0,0 +1,14 @@
node_modules
dist
.env
*.log
screenshots/
qa-shots/
output/
.gev-logs/
.gev-cache/
.DS_Store
.gstack/
3d-models/
pinokio/ENVIRONMENT
pinokio/.installed
+22
View File
@@ -0,0 +1,22 @@
dist
.gev-cache
.gev-logs
screenshots
qa-shots
output
coverage
3d-models
pinokio/ENVIRONMENT
pinokio/.installed
src/data/local_data/
*.geojson
*.geojsonl
# Local configuration, generated assets and browser evidence
.env
.env.*
!.env.example
node_modules
.vite
.vite-*
*.min.js
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"tabWidth": 2,
"semi": true,
"endOfLine": "lf"
}
+501
View File
@@ -0,0 +1,501 @@
# 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
- Tightened the README opening around keyless setup, source freshness, modeled
experiences, and the accessibility of the provider stack.
### Fixed
- Pinokio now recognizes its nested successful-install marker, so a completed
one-click install exposes Start instead of returning to Install.
- The keyless `dev-fresh.sh` startup summary now names Esri World Imagery with
keyless terrain and identifies OpenStreetMap as the fallback.
- All three VIIRS sources now reach the Active Fires layer. Merging a source's
detections used argument spread, which exceeds the engine's argument limit on
the two largest sources and dropped them entirely — leaving roughly a third of
global detections while reporting each dropped source twice, once as
successful with its real count and once as failed.
- `./scripts/dev-fresh.sh` no longer crashes on stock macOS bash 3.2 when no
provider keys are exported: expanding the empty external-keys provenance
array under `set -u` was fatal there. Launches with exported keys are
unchanged.
### Security
- GBFS proxy body-size cap now measures the response in bytes
(`Buffer.byteLength`) instead of JavaScript string length, so the
`GBFS_MAX_BODY_BYTES` limit holds for multi-byte payloads and cannot be
overrun by non-ASCII upstream responses.
## [0.1.0] — 2026-08-31 — One-click install, keyless boot, Provider Settings
### Added
- **One-click install** via Pinokio. Keyless boot lands on a live Esri World
Imagery satellite globe with keyless terrain; OSM takes over automatically if
Esri is unreachable, and the globe continues without terrain if its source is
unavailable.
- **Provider Settings** (the POWER UP panel): add, replace, or remove API keys
inside the app. Credential files are made owner-only before any secret is
written — verified on macOS and Windows — and keys configured outside the
panel are shown read-only, never rewritten.
- **Keyless capability responses**: the optional HUD summary and place-search
endpoints return a deliberate "not configured" success instead of errors, and
never consume rate-limit quota.
- `.gitattributes` normalizes line endings, so Windows clones pass the full
test suite out of the box (#81 — thanks @ethanstoner).
### Changed
- README rewritten keyless-first around the provider ladder: zero keys → free
Cesium ion (eligible personal, non-commercial use) → billing-enabled Google
Maps.
- Browser-built data modules no longer import `node:fs`; a repo-wide boundary
scan test keeps it that way (#83 — thanks @ethanstoner).
- Aircraft-identity voice answers explicitly cover operator, type, and route,
and say so plainly when enrichment is unavailable instead of guessing.
### Security
- Provider Settings answers only local, unproxied requests and disables itself
entirely whenever the server is shared. Public datacenter and dam datasets
omit contact-oriented fields (see the dataset READMEs).
## Pre-release development history
The dated entries and internal milestone numbers below predate the first
tagged GitHub Release. They are retained as project history and do not
represent previously published GitHub Releases.
## [Unreleased] — 2026-08-24
### Added
- Added honest aircraft identity narration: callsign, operator, registration,
type, and route come only from selected-contact context, and missing operator,
route, or type enrichment is named explicitly.
- Added local, publication-compatible copies of the two README PNGs, with source
records and third-party-license boundaries in `docs/media/README.md`.
- Added regression coverage for aircraft identity narration and optional-key
loading feedback.
### Changed
- First-run presentation now opens with Detection `DENSE` at 75%, `ELASTIC`
allocation, Fade 7%, Outside 1%, scope feather 11%, and aircraft 3D models in
`PROXIMITY`. Stored state and share links still override these baselines.
- The 17 selected README GIFs remain unchanged and are documented separately
from the two owner-published PNGs.
- Bundled datacenter and dam snapshots now omit contact-oriented fields and
note values containing email or phone identifiers. Feature geometry, names,
operator/capacity/river metadata, counts, and ODbL terms are unchanged.
- Public documentation and the L9 release matrix no longer reference non-public
planning material or repository history.
### Fixed
- A missing optional FIRMS key no longer turns the complete Environmental
mission into `LOAD FAILED`. The FIRMS row still reports `KEY REQUIRED`, while
earthquakes continue to load. Real lifecycle and fetch failures retain
failure priority.
- The mapped-installations layer retries after an unavailable request when it is
enabled or the camera settles.
- Aircraft trails attach to the rendered aircraft transform and remain near the
rear center across headings. Parked aircraft do not draw a moving head
segment.
- Grounded aircraft keep validated floor evidence through temporary terrain
outages and wait for measured photoreal-surface evidence before a 3D model
takes over from its billboard.
- Cockpit altitude uses aviation MSL data rather than Cesium render height.
### Security
- Production transitive dependencies resolve to patched DOMPurify and
protobufjs releases without changing the Cesium version or application APIs.
- Production dependency audit reports no known advisories; remaining audit
findings are confined to development and QA tooling.
## [Unreleased] — 2026-08-23
### Added
- Added a first-run mission launcher for Contacts, Space Missions,
Environmental, and manual exploration.
- Added terrain-validity gating and bounded last-known placement for grounded
aircraft models.
### Changed
- Environmental consistently presents both earthquakes and NASA FIRMS fires,
with honest optional-key degradation.
- The tracked aircraft trail acceptance bar is visual: roughly rear-center,
stable across headings, with minor hull overlap allowed and no conspicuous
top, bottom, or lateral projection.
## [Unreleased] — 2026-08-18 to 2026-08-22
### Added
- Added the four-source Map Source tray, share-link v2 state, cockpit/context
voice parity, MSL altitude readouts, and close-range tracked aircraft models.
- Added the L9 release-candidate matrix, AIS feed watchdog, voice cost controls,
satellite classes, and the shared world-overlay host.
- Added deterministic first-run, map-source, floor, overlay, tracking, and
aircraft-model regression harnesses.
### Changed
- Consolidated world labels, cards, tracked readouts, CCTV thumbnails, cable
labels, mission labels, and detection presentation under shared allocation and
lifecycle rules.
- Reduced idle rendering through the render governor and explicit scope mask.
- Improved cockpit layout, context restoration, keyless feed honesty, and
aircraft 2D/3D handoffs.
### Fixed
- Fixed degenerate depth picks, map-source restore states, route-camera motion,
bright-ground label readability, grounded display flooring, and cross-layer
tracking cleanup.
- Fixed stale overlay callbacks, parked-idle render leaks, cable-label sweep
starvation, and several share-link state conflicts.
## [Unreleased] — 2026-08-02 to 2026-08-16
### Added
- Added Global Context modes, Cockpit briefing surfaces, Radio context,
satellite mission replay, and real per-class aircraft models with adjacent
provenance records.
- Added a shared screen-space overlay system with bounded allocation for labels,
cards, callouts, detection brackets, and selected-object presentation.
### Changed
- Unified right-side product controls and responsive cockpit/map layouts.
- Migrated public-safe neighborhood geometry to DataSF and tightened safe local
development defaults.
- Improved proxy resilience, annotation outline bounds, CCTV enable pacing,
contact de-emphasis, and deterministic visual stacking.
## [Unreleased] — July 2026
### Added
- Added live NASA FIRMS fires, optional live TomTom traffic, Caltrans and TfL
CCTV packs, CCTV viewsheds and direct-manipulation calibration, citywide CCTV
cards, Natural Earth regions, analyst queries, and voice routing QA.
- Added the end-to-end vertical-datum system for aircraft, vessels, CCTV,
annotations, trails, and terrain-aware rendering.
- Added aircraft class silhouettes, path-derived display heading, ADSBDB
enrichment, cached CelesTrak TLE lookup, and next-ISS-pass prediction.
### Fixed
- Fixed elevated-airport aircraft placement, vessel sea-surface placement,
close-zoom FIRMS anchors, antimeridian region framing, annotation resolution,
cross-layer tracking ownership, and CCTV projection lifecycle issues.
## [Unreleased] — June 2026
### Added
- Added OpenAI Realtime voice control, scene-aware entity context, viewport image
grounding, the AI HUD summary, live AIS vessels, infrastructure layers, map
source switching, free-text navigation, and server-side data proxies.
- Added hybrid map annotations, 3D aircraft, panoptic detection, tracking
harnesses, and public data attribution.
- Added MIT source licensing, security guidance, contribution guidance, data
source notices, and third-party asset boundaries.
### Changed
- Removed the experimental AI video-edit style and retained seven deterministic
visual styles.
- Moved Realtime text-history trimming to the server-side retention policy while
keeping only the latest viewport image in conversation context.
## [0.7.0] — 2026-02-18
- Added the Bikeshare Pulse layer and panoptic label improvements.
- Improved tracked-item boxes, post-render alignment, and CCTV projection
quality.
- Removed the experimental shift-drag CCTV calibration interaction.
## [0.6.0] — 2026-02-10
- Added the initial multi-layer 3D globe experience, visual styles, live
aircraft, satellites, earthquakes, CCTV, traffic, FIRMS, infrastructure, and
performance controls.
- Added entity inspection, tracking, scenes, keyboard controls, and shareable
views.
## [0.1.0] — 2026-02-09
- Initial project version.
+100
View File
@@ -0,0 +1,100 @@
# Contributing to God's Eye View
Thanks for being here. God's Eye View is an open foundation for live spatial intelligence in the browser, and it gets better when more people run it, break it, and extend it.
## Getting set up
Use Node.js 24.14.x or 26.x (also enforced by `package.json`).
```bash
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
nvm install 24.14.0
nvm use 24.14.0
npm install
npm run doctor
./scripts/dev-fresh.sh # or: npm run dev (keys are optional)
```
No key is required to start: the app boots on keyless Esri World Imagery with
keyless terrain, and OSM takes over automatically if Esri is unreachable.
Google Maps provides direct photorealistic 3D and place search; Cesium ion
provides ion-hosted Google 3D plus optional Bing/world-terrain stacks.
On macOS the launcher pulls optional keys from
the Keychain; on any platform you can pass them as env vars or use a `.env`.
People who only want to run the app can instead install the repository directly
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 `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).
## Architecture in one minute
- **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 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
- ES modules, **2-space indent, single quotes, semicolons.**
- JSDoc on exported/public functions.
- 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`.
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors.
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR.
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.
- Be decent to each other. Assume good faith, keep it constructive.
By contributing, you agree your contributions are licensed under the project's [MIT License](LICENSE).
+133
View File
@@ -0,0 +1,133 @@
# Data Sources & Attribution
God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources; bundled 3D-model provenance is recorded in [`public/models/README.md`](public/models/README.md).
How to read this:
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder.
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
---
## Live sources (fetched at runtime — not stored in this repo)
| Source | Used for | License / terms | Attribution |
|--------|----------|-----------------|-------------|
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., *"Bringing Up OpenSky"*, IPSN 2014 + opensky-network.org |
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" + "© OpenStreetMap contributors" |
| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" |
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
### Notes on the live sources
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
- **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts.
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
---
## Bundled snapshots (committed under `src/data/local_data/`)
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
| Dataset | Folder | License | Commercial use? | Attribution |
|---------|--------|---------|-----------------|-------------|
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" (+ Open Infrastructure Map) |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
### ⚠️ TeleGeography is bundled but NonCommercial
The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial**-**ShareAlike**). It is bundled so the cables layer works out of the box, but it is **not covered by this project's MIT license**. CC BY-NC-SA permits redistribution with attribution and share-alike — which is exactly how it ships here — but the **NonCommercial** clause means:
> If you use God's Eye View commercially, delete `src/data/local_data/telegeography_submarine_cables/` (or obtain a commercial license from TeleGeography). It is one self-contained folder; the rest of the app runs without it.
The richer structured dataset is licensed separately/commercially by TeleGeography.
### ODbL share-alike (datacenters, dams)
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a *modified* version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
### NASA FIRMS acknowledgement
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
Suomi-NPP) clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
transaction quota. Requires a free `FIRMS_MAP_KEY`
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
### Natural Earth physical regions (`natural_earth/`)
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
fetched from the canonical `nvkelso/natural-earth-vector` GitHub repo, commit
`ca96624a56bd078437bca8184e78163e5039ad19`, 2026-07-28): `ne_10m_geography_regions_polys`
(mountain ranges, deserts, plateaus, peninsulas, islands, …) → `regions.json` and
`ne_10m_geography_marine_polys` (seas, gulfs, straits, bays) → `marine.json`. They back the
voice-annotation resolver's named-natural-region lookup (`src/data/naturalEarthRegions.js`),
so "outline the Alps" draws the real range polygon offline.
Curation (provenance in each file's `meta` header): named features only, outer rings only,
Douglas-Peucker simplified at ~0.01° with coordinates rounded to 3 decimals, sub-20 km²
MultiPolygon crumbs and zero-area sliver artifacts dropped (7.3 MB source → 2.5 MB pack).
Natural Earth is **public domain** (no permission needed, no attribution legally required —
https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made with Natural
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
wiring (see below).
### DataSF Analysis Neighborhoods (`neighborhoods/`)
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
**"Analysis Neighborhoods"** dataset (41 neighborhood polygons; DataSF dataset `j2bu-swwd`,
catalog map view
[`p5b7-5n3h`](https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h)).
It backs the voice-annotation resolver's offline neighborhood-boundary lookup
(`src/data/neighborhoodPolygons.js`), so "outline Chinatown" draws the city's real
boundary polygon with no network dependency.
The dataset is licensed **PDDL 1.0** (Open Data Commons Public Domain Dedication and
License — public domain; the DataSF metadata declares `licenseId: "PDDL"`). No attribution
is legally required; we note the source here and in the folder's `SOURCE.md`, which records
the retrieval date (2026-07-30), exact download URL, license evidence, and the
deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m
Douglas-Peucker simplification, 6-decimal rounding).
---
## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
+54
View File
@@ -0,0 +1,54 @@
MIT License
Copyright (c) 2026 Bilawal Sidhu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
NOTE ON THIRD-PARTY DATA AND ASSETS — THE MIT LICENSE ABOVE COVERS THE SOURCE
CODE ONLY.
The datasets bundled under src/data/local_data/, and all data fetched from
third-party providers at runtime, are owned by their respective sources and
are NOT licensed under MIT. They are included/visualized for convenience and
remain subject to their own licenses and terms. Notably:
- TeleGeography Submarine Cable Map (src/data/local_data/
telegeography_submarine_cables/): CC BY-NC-SA 3.0 — NonCommercial +
ShareAlike. NOT for commercial use. If you use this project commercially,
remove these files or obtain a commercial license from TeleGeography.
- Datacenters and Dams (OpenStreetMap / Open Infrastructure Map extracts):
Open Database License (ODbL) 1.0 — attribution + share-alike on the data.
- NASA FIRMS active-fire snapshot: CC0 / U.S. public domain (citation
requested).
Live sources (Google Maps, OpenSky, adsb.lol, AISStream, CelesTrak, USGS,
OSM Overpass, City of Austin, GBFS) are fetched at runtime under each
provider's terms; some (e.g. Google Maps, OpenSky) restrict commercial use
and require you to supply your own credentials.
In short: the MIT grant does not extend to any third-party data. See
DATA_SOURCES.md for the full per-source license + attribution summary, and
remove any dataset whose terms don't fit your use.
The 3D models under public/models/ are also third-party works and are NOT
licensed under MIT. They remain under their individual licenses. See
public/models/README.md for the author, source, license, modification notice,
and attribution covering each model file.
+512
View File
@@ -0,0 +1,512 @@
<div align="center">
# 🌐 God's Eye View
[![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml)
### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real.
Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic, and public cameras. Hands-free voice control powered by a realtime AI agent.
*No place left behind.*
![Orbital HUD, a tracked live globe, FLIR terrain — then OPEN SOURCED](docs/media/hero-open-source-reveal.gif)
<a href="https://www.youtube.com/@bilawalsidhu">
<img src="docs/media/youtube-popular-videos.png" alt="The God's Eye View video series on YouTube" width="100%">
</a>
▶️ **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)
[![Reached #1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](https://x.com/bilawalsidhu/status/2093798887815348521)
🏆 **Reached #1 on GitHub Trending, daily and weekly · August 2026**
**[#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>
---
<div align="center">
**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys)**
</div>
---
## 🌍 Why This Exists
God's Eye View brings public signals into one explorable globe. Track the world live. Talk to it. Break it. Extend it.
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.
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.
Start with the included data sources, then add your own. Each layer is a separate module.
---
## 🎛️ What This Thing Does
- **🛩️ Cockpit view:** Ride inside a tracked flight — the camera holds the terrain under you all the way down.
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir, Snow.
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
- **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave.
- **🎥 Scene director:** Capture cinematic camera tours for clips and demos.
- **🔗 Share Links:** Camera, style, layers, and even one tracked target serialize into a URL — a live target is a handoff, not a bookmark.
- **🏠 Reset Globe:** One control — or one sentence — back to the full Earth.
---
<div align="center">
[![YouTube video about the God's Eye View open source release](https://img.youtube.com/vi/GRJaKcXZS94/maxresdefault.jpg)](https://www.youtube.com/watch?v=GRJaKcXZS94)
▶️ **[The full walkthrough of everything below, on YouTube](https://www.youtube.com/watch?v=GRJaKcXZS94)**
</div>
## ⚡ Quick Start
**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.
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 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**.
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
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
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`**. 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.
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
chip in the bottom-right corner: Provider Settings lists every supported key,
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
restarts itself with the new capability on. Once everything is configured the
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
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. 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
(eligible personal, non-commercial use; current terms and quotas apply) for
photorealistic 3D and world terrain; a Google Maps key only for the
billing-enabled, metered route + place search; OpenAI when you want to talk
to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
<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)
must be restricted at their providers — [SECURITY.md](SECURITY.md) shows how,
and it carries the LAN-sharing rules alongside [Keys & Costs](#-api-keys).
---
## 🕐 The First Five Minutes
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.
![Riding with a live aircraft in cockpit view while switching sensor modes](docs/media/06-cockpit-ar.gif)
3. **Drop into a busy airport.** Search one and descend to the taxiways with **3D** aircraft on — grounded contacts, taxi trails, the whole apron working in real time.
![Moving from a full airport overhead down to close taxiway inspection with 3D flight models](docs/media/start-here/airport-ground-traffic-google-3d.gif)
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
![Diving into an Austin intersection with a live public camera projected into the 3D scene](docs/media/03-austin-cctv.gif)
5. **Track something in orbit.** Turn on **Satellites** and click the ISS — you ride along at orbital distance, orbit ring and all.
![Tracking the ISS along its orbital path as it crosses over Ukraine](docs/media/14-iss-over-ukraine.gif)
6. **Switch the optics.** Tap `1`–`7` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor.
![Cycling a dense live globe through CRT, FLIR, and NVG in one continuous view](docs/media/01-style-sweep.gif)
7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."*
8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."*
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
---
## 🛩️ The Cockpit
> Every plane should let you do this.
Real-time cockpit mode, built from live flight data: the camera rides your contact with real terrain holding underneath, all the way down — sensor styles come along for the ride, and **Contacts** keeps the 250 km roster one click away: jump plane to plane and fall straight into the next cockpit.
![Jumping between live aircraft and falling straight into a cockpit view](docs/media/12-switch-aircraft-cockpit.gif)
The cockpit even carries its own briefing strip: nearby live signals, regional headlines, and real local weather — with an opt-in **WX** mode that renders volumetric clouds from actual observations around your aircraft.
![A live military contact ridden through Normal, NVG, and Ironbow FLIR with dense detection](docs/media/start-here/military-cockpit-dense-google-3d.gif)
*Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through.*
---
## 🎙️ Talk to It
> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask *"what city is this?"* mid-flight and it knows.
- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask *"what's this?"* It answers using the object's live telemetry.
- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels.
- **🎬 Cinematic framing.** *"Show me the planes overhead"* pulls the camera back, angles it, and frames the live traffic like a director.
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token.
Twenty-eight tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
**🎥 Direct it** — drone-operator camera verbs:
> 🗣️ *"Take me to Tokyo."* · *"Orbit around this area slowly."* · *"Draw the walking route from the Capitol to Zilker Park."* → *"Fly the route we just drew."* · *"Zoom out to a globe view."*
**🖊️ Annotate it** — a whiteboard over the real world:
> 🗣️ *"Outline the state of Texas."* · *"Annotate the Texas State Capitol and its grounds"* — it draws the **actual enclosing boundary**, not a circle. · *"How far is the Eiffel Tower from the Louvre?"* — a connector arrow appears and it speaks the distance. Everything persists until you say *"clear the map."*
![Zilker Park and Lady Bird Lake drawing onto the 3D city as persistent vector annotations, by voice](docs/media/01-voice-annotate-zilker.gif)
![A spoken distance measurement spanning an airport, inspected from orbit](docs/media/04-airport-distance.gif)
**🔎 Interrogate it** — analyst queries against the live layers:
> 🗣️ *"How many flights are over Texas right now?"* · *"Which ships are headed to Oakland?"* · *"What is the biggest fire near Los Angeles?"* · *"Is anything flying above forty thousand feet?"* · *"When does the ISS pass over next?"*
**🎛️ Operate it** — the whole console, hands-free:
> 🗣️ *"Switch to night vision and turn on the flights layer."* · *"Turn on the camera viewsheds."* · *"Play a news radio station near Austin."* · *"Track that plane."* → *"Enter Cockpit."*
**And the rapid-fire tier** — one sentence each:
> 🗣️ *"Show me global infrastructure."* (stages the layers and pulls back to the globe) · *"Play Orbital Watch."* (a full cinematic scene) · *"Set detection density to fifty percent."* · *"Next contact — helicopters only."* (mid-cockpit) · *"Show me space missions."* · *"Switch to OSM."* · *"Sharpen the image a touch."* · *"Switch to the tactical layout."* · *"What's turned on right now?"*
![The globe populating with the world's radio stations as another live layer](docs/media/15-global-radio-layer.gif)
*Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to.*
---
## 🛰️ What's on the Globe
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 |
|-------|--------------|--------|------|
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
| 🚢 **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** | 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 | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
**The basemap ladder — what each tier buys you:**
| You have | The globe you get |
|---|---|
| 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it |
| 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply |
| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route |
![A reconstructed Falcon 9 ascent climbing and curving into its projected orbit](docs/media/08-falcon9-replay.gif)
*The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×.*
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif)
**Missing a layer you want?** Open an issue — or add it and send the PR.
---
## 🎖️ Field Missions
Once the basics click, run these:
| Mission | How |
|---|---|
| **🚁 Ask the planet** | *"Why are all these military helicopters flying in circles?"* Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. |
| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. |
| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. |
| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. |
| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. |
| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. |
| **🚶 Ask for a walking route** *🎙️* | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then *"fly it"*: banked turns, eased ends, a camera that leads the path like a drone shot. |
| **📏 Measure LAX to DFW** *🎙️* | *"How far is LAX from DFW?"* — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. |
| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. |
| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. |
| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. |
*🎙️ = voice missions — they need an OpenAI key.*
![Resolving a selected aircraft's recent flight path into stacked 3D loops above the terrain](docs/media/07-helicopter-loops.gif)
*Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops.*
![Asking for a walking route and flying the generated path through the 3D city](docs/media/10-walking-route-flythrough.gif)
*"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot.*
![Descending from regional context into dense rows of retired aircraft at the boneyard](docs/media/08-boneyard.gif)
*Walk the boneyard: rows of retired airframes, fully resolved in 3D.*
---
## 🔧 Under the Hood
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.
- **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.
```
src/
├── main.js # Bootstrap: Google 3D tiles, layer registration
├── ui.js # Runtime UI — panels, HUD, styles, control facade
├── 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
├── 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
```
See [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md) for the authoritative runtime reference.
---
## 🔑 API Keys
🟢 **No key** · 🟡 **Free key** · 🔴 **Metered**
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.
### Choose the capabilities you want
Six keys. Four have a free tier, and the two 🔴 ones are metered:
| | Key | Why | Get it |
|---|-----|-----|--------|
| 🟡 | **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 signup |
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif)
*What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it.*
### Cherry on top
| | Key | Why | Get it |
|---|-----|-----|--------|
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
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.
On macOS its Keychain-aware result previews `./scripts/dev-fresh.sh`; plain
`npm run dev` reads only explicit environment and Vite dotenv values. The
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.
<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:
OPENAI_API_KEY="…" AISSTREAM_API_KEY="…" npm run dev -- --host localhost --port 4173
# On macOS, store any of them in the Keychain and dev-fresh.sh pulls them in:
security add-generic-password -U -s "google-maps-api" -a "api-key" -w
security add-generic-password -U -s "openai-api" -a "api-key" -w
security add-generic-password -U -s "aisstream-api" -a "api-key" -w
security add-generic-password -U -s "firms-map" -a "map-key" -w
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:
| | Cost reality |
|---|---|
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic
3D Tiles sessions each month are currently free, and one root request supports
roughly three hours of rendering. A solo user exploring sparingly can
realistically stay inside the free usage cap. Billing must still be enabled, so
restrict the key and set a quota or budget alert. Check Google's
[current pricing](https://developers.google.com/maps/billing-and-pricing/pricing)
before relying on these figures.
### 🧗 The floor is low on purpose
Everything above is the deliberately cheap baseline — enough to get a real taste of geospatial intelligence, GEOINT, and OSINT without ever talking to a sales team. You'll also notice the ceiling: terrestrial AIS goes quiet mid-ocean and satellite AIS costs real money; premium imagery, SAR, and the deeper commercial feeds live behind enterprise contracts. That's not a limit of the architecture — every layer here is a pattern you can point at your own data sources. This repo hands you the foundation; what you fuse into it is up to you.
### 🔒 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, **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 is disabled when the server is shared, so remote users cannot
access the key-entry panel.
**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.
---
## 📋 Responsible & Open
God's Eye View runs on **public data, clear sources, and local-first execution.** No secrets, no private datasets, no mystery scraping — anything involving a private key is brokered server-side. It has the visual grammar of a classified ops room, built entirely from open signals and inspectable code.
**The line.** This project models **events, assets, infrastructure, and systems** — aircraft, vessels, satellites, fires, cameras, cities. It does not build features for named-person search, face recognition, or tracking individuals, and pull requests that cross that line won't be merged. People are not a query type here.
**Come build it.** This is the canonical live 3D client from the project that kicked off the recent wave of spatial-intelligence tools — and it's a canvas: the layers here are the signals one person could find and fuse. Add a city pack, a data source, a style, a voice tool. It's the window through which you see the world; bring that window to others.
**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]
> God's Eye View is an exploratory visualization of public and third-party data.
> Data may be delayed, incomplete, modeled, inferred, or wrong. Do not use it
> for flight or maritime navigation, emergency response, medical or health
> decisions, investment decisions, or other safety-critical or operational
> purposes. Verify important information with authoritative sources.
---
## 🧭 What's Next
First — thank you. To everyone who watched the God-view demos and went off to build their own, and to everyone who kept asking for the code: I'm grateful. And when I polled whether this should go open source, you weren't subtle about it:
<img src="docs/media/open-source-survey.png" alt="Community survey on open-sourcing God's Eye View" width="460">
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. 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.
---
<div align="center">
▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project
**🌐 God's Eye View. No place left behind.**
</div>
+97
View File
@@ -0,0 +1,97 @@
# Security
God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly.
## Reporting a vulnerability
Please report security issues **privately** — do not open a public issue for anything exploitable.
- Use GitHub's [private vulnerability reporting](https://github.com/bilawalsidhu/gods-eye-view/security/advisories/new) (Security tab → "Report a vulnerability"), or
- Reach the maintainer directly via the contact on the GitHub profile.
Include repro steps and impact. We'll acknowledge, investigate, and credit you (if you'd like) once a fix ships.
## How secrets are handled
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
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
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 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.
The official Pinokio launcher stores optional values in its ignored local
`pinokio/ENVIRONMENT` file and Vite explicitly denies that filename. Add,
replace, or remove those values through the in-app **POWER UP → Provider
Settings** panel; the server restricts the file before writing and restarts the
local app after a save. Do not submit credentials through Pinokio 8.0.40's
native Configure form: that release targets the wrong file for this nested
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 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.
- **Response-size caps and timeouts** on proxied responses.
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
## Network exposure — the operator threat model
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks).
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
- **App-level throttles (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and are **not billing caps**.
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
Pinokio release re-reads sharing state when an app registers its Open URL and
logs a successful tunnel-login passcode in its own notification and terminal
stream. Before preflight, the launcher rewrites its app-scoped sharing controls
to disabled values, clears any Pinokio-global passcode from the child, and
pins the platform share trigger to a disabled sentinel. A stale or requested
sharing value is therefore discarded rather than honored, and GEV starts on
loopback only. Use a separately reviewed authentication proxy for remote
access and keep provider-side quotas as the spend backstop.
## Scope & expectations
- The Vite server is a **development/preview** server. If you expose it beyond localhost, put it behind your own auth/proxy and review the bindings (see the threat model above).
- All data shown is from **public** sources. See [DATA_SOURCES.md](DATA_SOURCES.md). Respect each provider's terms and rate limits.
- The voice agent receives feed-sourced text (place names, callsigns) as scene context. It is instructed to act only via a fixed set of app-control tools and not to execute arbitrary instructions found in data, but treat model output as untrusted and keep the tool surface limited.
## Responsible use
This is an interface for signals that are **already public**. Use it accordingly: respect privacy, follow data providers' terms, and don't represent public-data inference as authoritative intelligence.
+233
View File
@@ -0,0 +1,233 @@
# God's Eye View — Testing Guide (voice map-annotation whiteboard + live tracking)
> [!NOTE]
> This is a **manual field-test scenario script** for the June-2026 whiteboard +
> tracking work. The AUTOMATED gates live elsewhere: `npm test` (unit),
> `npm run test:track` (tracking invariants), and the headless harnesses under
> `scripts/qa-*.mjs` — together these are the full automated test surface.
This guide covers the work hardened over **4 adversarial-review batches** on
`feat/annotate-hybrid`. Record a voice note + screenshots as you go; each scenario
lists what **✅ pass** looks like and (where it applies) the **❌ old bug** it replaces.
## Focus/horizon moving evidence
With the Vite development server already running on port 4173, capture all four
focus/recession scenarios with a deterministic virtual frame clock:
```sh
node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
--screenshots-dir qa-shots/focus-evidence \
--json qa-shots/focus-evidence/report.json
```
For a quick operator loop, `--smoke` captures only S1 in six frames. For visual
sign-off, add `--headful`; this removes the SwiftShader launch flags and uses
the machine's real GPU. The harness brings its page to the foreground at the
start of every scenario so a headful Chromium run keeps streaming tiles.
Google 3D remains the default basemap. If its tile stream is the bottleneck
rather than the behavior under test, select an existing map stack explicitly:
```sh
--basemap bing-aerial
--basemap osm
```
Accepted values are `photoreal`, `bing-aerial`, `bing-labels`, and `osm`; Bing
stacks still require the app's usual Cesium ion token.
Tune both systems without editing source by adding, for example:
```sh
--params '{"focus":{"dimFloor":0.35,"nearerBehavior":"partial"},"horizon":{"scaleFloor":0.5,"alphaFloor":0.4}}'
```
The script never starts the server. During scenarios it pauses Cesium's default
render loop, advances focus time explicitly, and renders each frame before the
screenshot, so computed alpha/scale sequences repeat for identical parameters.
Headless Chromium still forces SwiftShader, so its pixels are relative CI/A-B
evidence only; headful real-GPU output is the sign-off surface. Screenshots use
scenario/frame names and the JSON report records effective tuning plus
per-contact alpha, scale, and screen data. Before each scenario's first capture,
the harness gives the active Google photoreal tileset up to 45 seconds to reach
Cesium's `tilesLoaded`/`allTilesLoaded` condition. Every frame records
`tilesSettled: true|false` and whether that gate applied; a timeout is recorded
as false, never promoted to a pass. Non-photoreal stacks record the gate as
settled and not applicable because no Google 3D tileset is active.
> [!IMPORTANT]
> Do not use a screenshot for visual judgment unless its report frame records
> `tilesSettled: true`.
## Setup
- **URL:** http://localhost:4173 — auto-flies to Austin on load. Give photoreal tiles ~10s.
- **Voice (the real feature):** click **GEV MIC** (bottom of screen) → wait for **LISTENING** →
just talk. It marks the map *as it talks*, without announcing that it's drawing. Click
**STOP** when done. (Needs `OPENAI_API_KEY`; `dev-fresh.sh` injects it from Keychain.)
- **Console (deterministic, no mic/live-data needed):** open DevTools (**Cmd-Opt-J**) and use:
- `window.__gevAnnotations.tour()` — self-running narrated SF tour (camera + marks in sequence)
- `window.__gevAnnotations.demo()` — lays the SF set down at once
- `window.__gevAnnotations.clear()` — erase all marks
- `window.__gevAnnotations.count()` — how many marks are live
---
## What changed (the list)
**Live aircraft tracking & camera (Batch D)** — civilian + military, mirrored:
- **R14** warm-up: tracked plane glides immediately (no freeze-then-jump / backward snap),
via a display-layer reconciliation that smooths any position discontinuity over ~0.9s.
- **R15** framing: calibrated initial follow distance (altitude-scaled, 3–30 km).
- **R16** trail: head stays glued to the moving icon per-frame; never trails ~1s behind or
pokes out in front of the nose.
- **R13** z-order: screen annotations fade where they'd cover the **tracked** plane + its label.
**Voice annotation — scoping & honesty (Batch B):**
- **R1** resolver scope: a place name resolves to the *right* thing (building vs neighborhood
vs natural feature vs city) instead of an oversized admin blob.
- **R2** boundary stitching: complex outlines no longer close with bay-spanning chords.
- **R6** route honesty: a routing outage draws a *labeled* straight "direct line", never a
silently faked road.
- **R12** partial honesty: if some targets fail, it says so — no masking partial as success.
- **R10** cache: transient failures aren't negative-cached for the whole session.
**Annotation rendering & motion (Batch C):**
- **R8** route flow: route dashes **animate** (flow along the path).
- **R9 / R17** perf: idle annotations stop their animation loop; height cache is bounded.
- **R11** no-freeze: large/complex boundaries simplify without locking the UI.
**Abuse guards (Batch A)** — mostly internal: input caps on `annotate_map`, clear/new-topic
cancellation, hardened Overpass + route proxies. Not much to see by hand (see §4).
---
## 1. Live aircraft tracking (Batch D) — the 3 issues you reported + z-order
> Needs a live plane. Track by **clicking a plane icon**; **Esc** (or click empty space) untracks.
> If you see no planes, scroll out once — Texas airspace is busy.
**1a · Warm-up smoothness (your issue #1 → R14)**
1. Click a moving plane. 📸 Snap the instant it locks, then watch ~2–3s.
- ✅ Starts gliding smoothly *immediately*. ❌ *Was:* froze ~1s, then jumped forward.
**1b · Initial framing (your issue #2 → R15)**
1. On the same lock-on, look at how the camera frames the plane. 📸 Snap the initial view.
- ✅ Plane + label readable with context, no scrolling. ❌ *Was:* jammed in too tight.
**1c · Trail glued to the plane (your issue #3 → R16)**
1. Keep tracking ~10s. 📸 Snap the cyan trail while it moves.
- ✅ Trail tail stays attached to the plane; nothing pokes out *in front* of the nose; no ~1s lag.
❌ *Was:* trail lagged a beat behind.
**1d · Military layer (same fixes, mirrored)**
1. Enable the **Military** layer (left column). Track an **amber** plane. Repeat 1a–1c.
- ✅ Identical smooth behavior.
**1e · Z-order: tracked plane stays on top (→ R13, trickiest to stage)**
1. While tracking a plane over Austin, GEV MIC: *"annotate downtown Austin."*
2. Orbit/zoom so the plane crosses the annotation's outline or label. 📸 Snap the overlap.
- ✅ The annotation **dims** where it covers the plane/label — plane stays visible on top.
❌ *Was:* the annotation drew over and hid the plane.
---
## 2. Voice annotation — scoping & honesty (Batch B)
> All from the Austin default view (no camera move needed). Click GEV MIC, then speak.
**2a · Scope: building vs region (→ R1)** — say each, watch what gets outlined:
- *"annotate the Texas State Capitol"* → ✅ the **Capitol building**, not the whole state.
- *"annotate Barton Creek"* → ✅ the **creek / greenbelt**, not Barton Creek *Mall* or the city.
- *"annotate downtown Austin"* → ✅ the **downtown district**, a sensible neighborhood-size area.
- *"annotate the University of Texas at Austin"* → ✅ the **campus**, not the city.
- 📸 Snap each outline. ❌ *Was:* incidental names ballooned into giant county/state blobs.
**2b · Classic SF set (optional)** — first say *"take me to San Francisco,"* then:
- *"annotate the Mission District"* → neighborhood. *"...the Presidio"* → the full former-base
outline (not a single point, not a bay-spanning blob → R2). *"...Chinatown"* → the district.
**2c · Route honesty (→ R6)**
1. *"draw a route from Austin-Bergstrom airport to the Texas Capitol."*
- ✅ A real **road** route with a flowing dashed line + a labeled callout.
- If a route ever comes back as a **straight** line, it should be **labeled** as a direct/approx
line (not pretending to be a road). ❌ *Was:* silent fake straight-line "routes."
**2d · Partial-failure honesty (→ R12)**
1. *"annotate the Texas Capitol and the Flibbergibbet Building."*
- ✅ It annotates the Capitol **and tells you** it couldn't find the other one.
❌ *Was:* reported success and silently dropped the missing one.
**2e · Clear / new-topic race (→ R4)**
1. *"annotate downtown Austin"* and, right as it starts, *"actually, clear everything."*
- ✅ Marks clear and **stay** cleared (the in-flight one doesn't pop back a second later).
- Sanity in console: `window.__gevAnnotations.count()` → `0` after a clear.
---
## 3. Annotation rendering & motion (Batch C) — deterministic, no mic
**3a · Full experience (smoke test)**
1. Console: `window.__gevAnnotations.tour()`. 📸 A couple of frames as it runs.
- ✅ Camera flies to SF; Palace highlight → arrow → Presidio **draped** outline → ILM pin →
Crissy Field **route** appear in sequence; marks stay glued as the camera moves.
**3b · Route dashes animate (→ R8)**
1. During/after `tour()` (or `demo()`), watch the **route** line (Crissy Field shoreline).
- ✅ The dashes **flow** along the path (animated), not static. ❌ *Was:* dashes never moved.
**3c · Draping & persistence**
1. After `demo()`, orbit the camera around the Presidio outline.
- ✅ The footprint **drapes onto** the 3D ground/buildings and conforms; callouts/rings/arrows
are the hand-drawn SVG style and stay anchored to their real spot.
**3d · Large boundary, no freeze (→ R11)**
1. Console:
`window.__gevAnnotations.annotate([{type:'area',target:'Travis County, Texas',label:'Travis County',color:'green',footprint:true}],{flyTo:true,persist:true})`
- ✅ The complex county boundary simplifies and draws **without** freezing/janking the UI.
---
## 4. Robustness / guards (Batch A) — mostly internal
These are server-side / defensive and don't have a clean visual tell. Light checks only:
- Rapid-fire several annotate commands, then a clear — UI should stay responsive and end clean
(`count()` → `0`).
- Nothing here should change normal behavior; flag it only if something feels *broken* (stuck
spinner, marks that won't clear, errors in the console).
---
## 5. General feel — open feedback
While recording, call out anything in these areas — this is the feedback I most want:
- **Tracking feel:** does follow motion feel smooth/natural, or floaty/laggy/overshooting?
- **Framing:** is the initial tracked view a good "hero" shot, or too close/far?
- **Annotations:** do outlines land on the *right* thing? Are labels readable / well-placed /
not overlapping? Does the hand-drawn style read well over photoreal tiles?
- **Voice:** does it mark things *as it talks* (not after), and confirm only what actually
happened? Any command it misunderstood?
- **Anything that looks wrong, janky, or surprising** — screenshot it; that's the gold.
## If something looks off
- **Grey globe / slow tiles:** wait a few seconds after a camera flight; photoreal streams in.
- **A voice mark didn't land:** the place may not geocode — try a more specific name
(e.g. "Palace of Fine Arts, San Francisco").
- **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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

+35
View File
@@ -0,0 +1,35 @@
import cesium from 'vite-plugin-cesium';
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
plugins = [],
googleApiKey,
cesiumToken,
host = 'localhost',
port = 4173,
} = {}) {
return {
plugins: [cesium(), ...plugins],
server: {
host: host || 'localhost',
port: parseInt(port, 10) || 4173,
allowedHosts:
host === '0.0.0.0' || host === '::'
? true
: ['localhost', '127.0.0.1', '.local'],
fs: {
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
},
// These headers protect the document containing Provider Settings.
headers: {
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
},
},
define: {
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
},
build: { chunkSizeWarningLimit: 1500 },
};
}
+1
View File
@@ -0,0 +1 @@
[]
+59
View File
@@ -0,0 +1,59 @@
[
{
"id": "tokyo-shinjuku-east-1",
"name": "Shinjuku Crossing East Cam",
"city": "Tokyo",
"cityId": "tokyo",
"provider": "Pilot Feed Pack",
"sourceKind": "pilot",
"feedType": "mp4",
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
"lat": 35.689614,
"lon": 139.700523,
"headingDeg": 242,
"pitchDeg": -19,
"fovDeg": 66,
"rangeM": 560,
"mountHeightM": 29,
"groundElevationM": 40,
"license": "Demo sample stream for projection pipeline testing"
},
{
"id": "tokyo-shinjuku-west-2",
"name": "Shinjuku Station West Cam",
"city": "Tokyo",
"cityId": "tokyo",
"provider": "Pilot Feed Pack",
"sourceKind": "pilot",
"feedType": "mp4",
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
"lat": 35.690976,
"lon": 139.699237,
"headingDeg": 128,
"pitchDeg": -17,
"fovDeg": 72,
"rangeM": 600,
"mountHeightM": 32,
"groundElevationM": 40,
"license": "Demo sample stream for projection pipeline testing"
},
{
"id": "tokyo-shibuya-scramble",
"name": "Shibuya Scramble North Cam",
"city": "Tokyo",
"cityId": "tokyo",
"provider": "Pilot Feed Pack",
"sourceKind": "pilot",
"feedType": "mp4",
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
"lat": 35.659695,
"lon": 139.700539,
"headingDeg": 26,
"pitchDeg": -20,
"fovDeg": 74,
"rangeM": 610,
"mountHeightM": 30,
"groundElevationM": 35,
"license": "Demo sample stream for projection pipeline testing"
}
]
+102
View File
@@ -0,0 +1,102 @@
# Application construction
`gods-eye-view/application` exports `createApplication`. Importing it does not
create a viewer, discover configuration, start requests, or attach browser
listeners. Construction is also inactive until the caller invokes `start()`.
The caller supplies four constructors, in this order:
| Constructor | Receives | Standalone implementation |
| ---------------- | ---------------------------------------------- | --------------------------------------------- |
| `createScene` | `signal`, `defer` | Viewer, attribution and initial map |
| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation |
| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration |
| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners |
Each constructor returns its component object, or a promise for that object.
Those objects are passed unchanged to later constructors. Configuration and
service instances belong in the caller's closures; the application does not
interpret provider names, environment variables, endpoints, or module paths.
There is no module discovery or automatic import mechanism.
```js
import { createApplication } from 'gods-eye-view/application';
import { createApplicationViewer } from 'gods-eye-view/application/viewer';
const app = createApplication({
createScene({ defer }) {
const viewer = createApplicationViewer({ container, creditContainer });
defer(() => viewer.destroy());
return { viewer };
},
createControls,
createData,
createTools,
});
const unsubscribe = app.subscribe(({ status, phase }) => {
// Update the caller's startup presentation.
});
await app.start();
// When the application is no longer needed:
unsubscribe();
await app.destroy();
```
The example's containers and three remaining constructors are supplied by the
consumer. The viewer helper preserves the standalone viewer's render settings;
it neither selects map sources nor reads keys. Supply a visible credit container
and retain all attribution required by the chosen sources. Cesium is external to
this export: the consuming build must provide the same instance used elsewhere
in its application.
## Lifecycle and ownership
Register `defer(cleanup)` immediately after acquiring each resource, before any
`await`. Cleanup registration remains open until that constructor settles. Each
callback may return a promise. Within a phase, callbacks run in reverse order.
Across phases, teardown runs **tools, controls, data, scene**: controls must cancel
restoration while their data manager and viewer are still alive.
`start()` returns the same promise on repeated calls. `destroy()` is terminal and
also returns the same promise on repeated calls. It aborts the shared signal
immediately, waits for an in-flight constructor to settle, and runs all registered
cleanup callbacks. Constructors must forward the signal to cancellable operations
and check it after awaits. If a provider cannot cancel construction, register
cleanup for its late result before returning. Destruction waits for that result;
it does not pretend a pending resource has already been released.
A constructor failure triggers the same cleanup before rejecting startup.
Cleanup failures do not stop the remaining callbacks; they are reported through
an `AggregateError`. Constructors own cleanup for resources created internally,
including anything allocated before their own construction throws.
`getState()` returns a frozen `{ status, phase }` snapshot. Status is `created`,
`starting`, `ready`, `destroying`, `destroyed`, or `failed`. `phase` identifies the
constructor during startup. `subscribe(listener)` immediately reports the current
snapshot and returns an unsubscribe function. Install observers before `start()`
to receive every startup phase. Observer failures cannot interrupt startup or
teardown. `getComponents()` returns a frozen, shallow snapshot of the currently
constructed component objects; the component instances themselves remain mutable.
`ready` means all constructors have returned. Background feed activity and the
standalone share-restoration result keep their existing separate contracts.
## Standalone wiring
`src/main.js` reads the existing browser configuration and starts
`src/standalone/application.js`. That module selects the four implementations
in its directory. Scene setup, controls, layer registration, tools and loading
chrome have separate owners. The existing `window.__godsEyeView` debugging shape
is preserved while the app is running.
The standalone controls and layer modules still contain page-scoped state.
Only one standalone application may be constructed per page. Shutdown releases
its runtime resources and debugging handles; reload the page to start again.
It is not an embeddable, removable HTML shell or a multiple-viewer implementation.
Consumers of the small application export supply their own component ownership;
the lifecycle controller itself has no shared instance state.
Later component extractions can replace a constructor's internals without
changing the startup contract or moving standalone imports into the package.
Run the unit suite, package boundary gate, build, tracking and first-run browser
checks when changing this wiring.
+312
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
# Reusing infrastructure layers
`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
It returns fresh datacenter and dam layers in that order, with the existing IDs,
appearance, label budgets and bundled public datasets. Importing the module or
calling the factory does not load data, create a viewer, or start the application.
The asset URLs resolve relative to the module; Vite rewrites them for production
base paths. This browser source export expects a Vite-compatible asset build.
Pass the application's existing functions:
```js
import { createInfrastructureLayers } from 'gods-eye-view/infrastructure';
const layers = createInfrastructureLayers({
overlayHost: { setEntries, setVisible, clearSource },
registerEntityContext,
selectEntityContext,
clearSelectedEntityContextForLayer,
removeEntityContextsForLayer,
governorRequestRender,
});
// Register these layers with the existing data-layer manager and viewer.
```
| Function | Arguments / responsibility |
| --- | --- |
| `overlayHost.setEntries` | `(sourceId, entries, options)`; publish the existing infrastructure overlay records and paint budget |
| `overlayHost.setVisible` | `(sourceId, visible)`; toggle a source |
| `overlayHost.clearSource` | `(sourceId)`; remove that source's overlay entries |
| `registerEntityContext` | `(entity, metadata)`; register selectable feature metadata, including the owning layer and data source |
| `selectEntityContext` | `(entity)`; select that registered feature |
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`.
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay
host permits one live instance per layer ID; destroy an old instance before
replacing it. Separate applications may use identical IDs with separate hosts.
Callbacks must be ready before enable and remain usable through destruction.
Disable retains the loaded dataset for reuse and clears selection and listeners.
Concurrent enables share one load. Destroy is permanent, aborts pending fetches,
discards late results, and clears the owned data source, context records, overlays,
input handler and listeners. The layer never creates an application context store,
overlay host, viewer, or render scheduler. The consumer must resolve one compatible
Cesium installation shared with its viewer; it must not bundle separate copies.
`gods-eye-view/infrastructure/geojson` exports the lower-level
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
The standalone app's `src/data/localGeojson.js` keeps its original single-argument
factory and supplies its existing functions. Other consumers should use the package
exports, which do not import standalone application globals.
Dataset files and source/license notices remain under `src/data/local_data/`.
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
expand multipart geometries into multiple entities; entity and stem counts are
different measurements. This change does not refresh or relicense the datasets.
+107
View File
@@ -0,0 +1,107 @@
# KNOWN ISSUES
Updated: July 8, 2026
This file tracks active runtime issues only.
For the roadmap and open backlog, see the repository issue tracker.
---
## Open
### Street traffic can be slow/uneven when panning across dense city blocks
Status: Open (partially mitigated)
Context:
- Current traffic loader fetches one clamped viewport tile at a time (major pass, then full pass).
- In dense cores, some visible roads can appear late after city jumps or fast pans.
- Zooming into adjacent streets does not always immediately trigger higher-detail coverage for all visible roads.
Current mitigation in runtime:
- Fair per-road dot budget allocation (reduces hard starvation under global `MAX_DOTS` cap).
- Center-shift threshold (reduces stale overlap lock while panning).
Next iteration candidates:
- Prioritize currently visible road segments inside the active viewport before off-center segments.
- Add neighbor prefetch ring for nearby tiles after jump-to-city actions.
- Add adaptive dot cap by frame time (coverage first, density second).
- Promote sync chip from loading indicator to true multi-phase progress.
---
### CCTV panel can appear "missing" after layout refactors
Status: Open (workaround available)
Context:
- Panel positions are persisted in local storage and can restore off-screen after UI changes.
Workaround:
- In browser console:
- `localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel');`
- `localStorage.removeItem('godsEyeView.v6.panelCollapsed.cctv-panel');`
- `location.reload();`
Related keys (current versions):
- Panel positions: `godsEyeView.v7.panelPos.<panel-id>` (re-versioned 2026-06-10)
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>`
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
---
### Height-datum residuals (branch `feat/height-datum`, pending merge)
Status: Open (owner-accepted 2026-07-08, documented)
- **Cold-start floor latency:** at a freshly-visited airport, grounded/low aircraft
float low for ~1–2 poll cycles (30–60 s) and rise as terrain floors resolve;
a few stragglers take one more poll.
- **Born-grounded first poll:** a contact first seen on the ground with no altitude
data renders at the geoid for ≤1 poll until its floor cell warms.
- Full context, improvement ideas, and the verification oracle
(`scripts/qa-floor-verify.mjs`):
`docs/superpowers/reports/2026-07-08-height-datum-handover.md`.
---
## Closed / Intentional (for clarity)
### Proxy SSRF and error-surface hardening gaps
Status: Closed as fixed on `main`
Context:
- Proxy middleware previously allowed broader error/internal surface area and looser upstream handling.
- Current `main` includes hardened proxy behavior in `vite.config.js`:
- CCTV upstream URL no longer accepted from client query params.
- Error payloads are sanitized.
- OpenSky cache stores successful responses only.
- OpenSky token refresh is coalesced.
- GBFS/CCTV memory growth is bounded.
Validation target:
- `vite.config.js`
---
### NVG vignette edge color bleed
Status: Closed as fixed in current shader composite
Context:
- Earlier builds leaked original scene colors near the NVG tube edge.
- Current composite now masks NVG output with tube falloff before final blend, removing the color edge bleed.
Validation target:
- `src/styles/surveillance.js`
---
### Wildfires layer unavailable / static bundled snapshot
Status: Closed — live FIRMS integration shipped (2026-07-16)
Context:
- Wildfires (NASA FIRMS) were removed from runtime in v0.5.3, returned June 2026 as a
bundled-snapshot layer (`local-firms`, 2026-05-25 data, ~58 MB in-repo), and were
converted to **live NASA FIRMS data** on 2026-07-16: the `/api/firms` proxy merges
three VIIRS NRT sources (trailing 24 h, 30 min cache, serve-stale-on-failure) and the
bundled snapshot was deleted. Requires a free server-side `FIRMS_MAP_KEY`; without it
the layer shows a KEY REQUIRED state.
- Weather radar is still held out of OSS v1 after QA found the previous overlay did not provide reliable visible value.
+144
View File
@@ -0,0 +1,144 @@
# Performance baseline
This page records one hardware-rendered Apple M5 comparison captured on 22
August 2026 in Chrome 150 at 1440 x 900. It is not a minimum hardware
specification and should not be used to predict performance on untested systems.
The original capture artifacts are not included here, so this page records
results rather than defining a runnable benchmark.
## Test context
The baseline was captured on 22 August 2026 with these conditions:
| Setting | Value |
| --- | --- |
| Renderer | Apple M5 Metal through the hardware ANGLE path |
| Browser | Chrome 150 in a fresh isolated profile |
| Viewport | 1440 x 900 at device pixel ratio 1 |
| Focus | Page foregrounded for controlled scenes |
| Scene sample | 5 seconds of scripted motion, then 5 seconds at rest |
| Startup | Browser cache disabled; three samples |
The capture covered three startup samples, 16 cold layer scenarios with 14
measurements, 23 controlled option and stress scenes, and five
hardware-rendered overlay scenes.
## Startup
| Sample | App ready | Initial settle | Load event | Motion / rest | Used JS heap |
| ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | 784.980 ms | 2,035.082 ms | 439.5 ms | 60 / 60 FPS | 102.9 MiB |
| 2 | 604.849 ms | 1,855.836 ms | 442.4 ms | 60 / 60 FPS | 111.6 MiB |
| 3 | 558.527 ms | 1,809.592 ms | 438.8 ms | 60 / 60 FPS | 105.1 MiB |
| Median | 604.849 ms | 1,855.836 ms | 439.5 ms | 60 / 60 FPS | 105.1 MiB |
The initial-settle measurement is the more useful launch reference because it
includes the first visual and data settling window. All three samples reached
the display ceiling during both motion and rest.
## Cold layer activation
Cold activation was measured separately from warm option switching. Live object
counts are included so that future runs can compare source populations before
attributing a difference to the client.
| Layer | Activation | Source count | Motion / rest | Used JS heap |
| --- | ---: | ---: | ---: | ---: |
| CCTV city | 19,608.240 ms | 48 | 60 / 60 FPS | 192.7 MiB |
| Space Missions (report label: Rocket missions) | 3,581.066 ms | 26 | 60 / 60 FPS | 131.3 MiB |
| Radio | 3,458.709 ms | 750 | 60 / 60 FPS | 124.8 MiB |
| Bikeshare | 2,069.498 ms | 633 | 60 / 60 FPS | 157.4 MiB |
| Datacenters | 817.693 ms | 4,362 | 59.6 / 60 FPS | 328.2 MiB |
| Flights | 667.671 ms | 247 | 60 / 60 FPS | 118.4 MiB |
| Submarine cables | 614.727 ms | 2,629 | 60 / 60 FPS | 412.0 MiB |
| Military Flights | 557.113 ms | 68 | 60 / 60 FPS | 118.4 MiB |
CCTV had the largest cold activation cost in this capture. Submarine cables
used the most heap, followed by datacenters. Completed single-layer samples
generally reached 60 FPS, so activation time and heap separate these cases more
clearly than steady-state frame rate.
## Aircraft, detection, and Cockpit
| Scene | Motion / rest |
| --- | ---: |
| Idle globe | 60 / 60 FPS |
| Flights, 2D | 60 / 60 FPS |
| Flights, 3D proximity | 60 / 60 FPS |
| Flights, all 3D models | 60 / 60 FPS |
| Military Flights, all 3D models | 60 / 60 FPS |
| Detection at 25% | 39.3 / 41.1 FPS |
| Detection at 50% | 37.4 / 39.8 FPS |
| Detection at 100% | 34.4 / 35.5 FPS |
| Cockpit | 49.6 / 49.2 FPS |
The clean detection scenes processed 8,169 to 8,170 observations. Selected
labels rose from 14 at 25% density to 28 at 50% and 56 at 100%. The aircraft
rows came from an earlier loaded, foreground-controlled pass because the clean
rerun received no live aircraft rows.
## Visual styles and combined stress
| Scene | Motion / rest |
| --- | ---: |
| Normal | 60 / 60 FPS |
| CRT (report label: Retro) | 60 / 60 FPS |
| NVG (report label: Surveillance) | 60 / 60 FPS |
| FLIR (report label: Thermal) | 49 / 60 FPS |
| Anime | 60 / 59.8 FPS |
| Noir | 47 / 56.6 FPS |
| Snow | 42.3 / 45.8 FPS |
| Combined static | 57.6 / 60 FPS |
| Combined operational | 39.9 / 43.1 FPS |
The combined static scene rendered 11,575 objects, used 872.2 MiB of JavaScript
heap, and issued 48,665 text draws during motion and 54,106 at rest. The combined
operational sample contained 3,909 observations and two selected labels, but its
live aircraft and traffic rows were empty, so it remains a limited stress case.
Snow, Noir, dense detection, and text-heavy combined layers are the clearest
controlled comparison points for later optimization work.
## Keyed live sources
NASA FIRMS, AISStream, and TomTom were captured in a separate hardware-rendered
pass. The page was visible but was not the focused window, so these frame rates
must not be compared directly with the foreground-controlled scenes above.
| Source | Point-in-time population | Activation or coverage | Motion / rest |
| --- | ---: | --- | ---: |
| NASA FIRMS | 100,430 detections in 3,557 cells | 30.0 s activation | 32.1 / 55.2 FPS |
| AISStream | 12,000 vessels | 6.4 s activation | 22.1 / 29.8 FPS |
| TomTom Traffic | 4,222 road dots | 70% coverage, 2 decoded tiles | 45.0 / 51.7 FPS |
These populations change continuously. A future comparison must record the
live counts again and match the focus conditions.
## Controls for a future capture
Use the same controls before attributing a difference to the application:
1. Record the exact GPU renderer and reject software-rendered or unavailable GPU
strings.
2. Use a 1440 x 900 viewport at device pixel ratio 1 and keep the page focused.
3. Measure cache-disabled startup separately from cold layer activation and warm
option switching.
4. Repeat startup three times and compare medians.
5. Sample each option for 5 seconds in scripted motion and 5 seconds at rest.
6. Record live object counts before attributing a difference to the client.
7. Treat a live-source outage as missing coverage, not as evidence of low client
rendering cost.
## What is not established yet
- This report does not establish Windows performance.
- The report does not record machine memory capacity, so it cannot support a
minimum-memory recommendation.
- The report does not cover other GPU renderers or viewport configurations.
- Military Installations is outside this comparison because it requires close
camera context.
- The keyed pass has no controlled rerun suitable for comparison with the
option scenes.
Use this page as a regression baseline for one known hardware and browser
configuration, not as a compatibility guarantee.
Binary file not shown.

After

Width:  |  Height:  |  Size: 981 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+40
View File
@@ -0,0 +1,40 @@
# README media provenance
## Capture GIFs
Bilawal Sidhu created and owns the following 17 GIFs and has authorized their inclusion and redistribution as part of this public repository and its project documentation. Any appearance by Bilawal Sidhu in these captures is included with his permission.
- `hero-open-source-reveal.gif`
- `06-cockpit-ar.gif`
- `start-here/airport-ground-traffic-google-3d.gif`
- `03-austin-cctv.gif`
- `14-iss-over-ukraine.gif`
- `01-style-sweep.gif`
- `12-switch-aircraft-cockpit.gif`
- `start-here/military-cockpit-dense-google-3d.gif`
- `01-voice-annotate-zilker.gif`
- `04-airport-distance.gif`
- `15-global-radio-layer.gif`
- `08-falcon9-replay.gif`
- `09-undersea-cables.gif`
- `07-helicopter-loops.gif`
- `10-walking-route-flythrough.gif`
- `08-boneyard.gif`
- `05-traffic-to-cctv.gif`
Copyright © Bilawal Sidhu. These files are not covered by the project's MIT License. Permission is limited to their inclusion and redistribution with this repository and its project documentation. No permission is granted for standalone reuse or modification. Commercial reuse outside this repository requires separate permission. No share-alike license applies.
The GIFs are promotional captures of God's Eye View. They include Google Photorealistic 3D Tiles and public or third-party data layers with in-frame attribution. Bilawal Sidhu's ownership and permission cover the captures and his likeness; they do not replace the terms of Google Maps Platform or any displayed data provider. Keep the visible attribution intact.
## Public README PNGs
Bilawal Sidhu published the following PNGs in the existing public `bilawalsidhu/gods-eye-view` repository and authorized their continued inclusion and redistribution with this repository and its project documentation:
- `youtube-popular-videos.png`
- `open-source-survey.png`
Source: [`assets/youtube-popular-videos.png`](https://github.com/bilawalsidhu/gods-eye-view/blob/7afb3beefb2dd39110ed117c8f06825e9ca2ada4/assets/youtube-popular-videos.png) and [`assets/open-source-survey.png`](https://github.com/bilawalsidhu/gods-eye-view/blob/7afb3beefb2dd39110ed117c8f06825e9ca2ada4/assets/open-source-survey.png) in public repository commit [`7afb3beefb2dd39110ed117c8f06825e9ca2ada4`](https://github.com/bilawalsidhu/gods-eye-view/commit/7afb3beefb2dd39110ed117c8f06825e9ca2ada4). The localized files preserve the source bytes unchanged.
These files are not covered by the project's MIT License. No permission is granted for standalone reuse or modification. Commercial reuse outside this repository requires separate permission. No share-alike license applies.
The authorization covers the PNGs as project promotional material. It does not claim ownership of platform interfaces, trademarks, avatars, data, or third-party imagery visible within the screenshots. Those elements remain subject to their respective owners' terms.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

+66
View File
@@ -0,0 +1,66 @@
# OpenSky Auth Setup
God's Eye View uses explicit auth modes for `/api/opensky`:
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
- `OPENSKY_AUTH_MODE=basic`
- `OPENSKY_AUTH_MODE=anon`
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
## Quick Start (OAuth Client JSON)
Import credentials from JSON (`clientId`/`clientSecret` or `client_id`/`client_secret`) into Keychain:
```bash
./scripts/opensky-import-client.sh ~/Downloads/credentials.json
# or: npm run opensky:import -- ~/Downloads/credentials.json
```
Then launch:
```bash
./scripts/dev-fresh.sh
```
Expected startup lines:
- `OpenSky auth mode: oauth`
- `OpenSky OAuth: configured`
## Optional: Launch With File (No Keychain Import)
```bash
OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json ./scripts/dev-fresh.sh
```
Launchers resolve OAuth creds in this order:
1. `OPENSKY_CLIENT_ID` + `OPENSKY_CLIENT_SECRET`
2. `OPENSKY_CREDENTIALS_FILE`
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
## Basic Auth (Legacy)
Basic auth is still supported for now, but OpenSky is moving away from it.
```bash
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
```
## Troubleshooting
Inspect proxy headers:
```bash
curl -si http://localhost:4173/api/opensky | grep -iE 'HTTP/|X-OpenSky-Auth'
```
Useful reasons:
- `oauth_invalid_or_missing`: OAuth client not found/usable
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
- `basic_invalid_credentials`: username/password rejected
- `missing_basic_creds`: basic mode missing user/pass
- `rate_limited`: OpenSky throttling
+928
View File
@@ -0,0 +1,928 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>God's Eye View</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="stylesheet" href="/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" />
</head>
<body>
<div id="cesiumContainer"></div>
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets">
<div id="world-overlay-action-list"></div>
<div id="world-overlay-status" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
<!-- Title -->
<div id="title-bar">
<div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle">NO PLACE LEFT BEHIND</p>
</div>
<!-- Style indicator -->
<div id="style-indicator">
<span class="indicator-label">ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name">NORMAL</span>
</div>
<!-- Top-center camera and share actions -->
<nav id="top-center-actions" aria-label="Globe actions">
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers">
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
</button>
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link">
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>
<span id="global-loading-label">LOADING LIVE DATA</span>
<span id="global-loading-detail"></span>
</div>
<div id="traffic-sync-chip" aria-live="polite">
<span id="traffic-sync-label">syncing road network</span>
<span id="traffic-sync-progress">0%</span>
</div>
<div id="cctv-sync-chip" aria-live="polite">
<span id="cctv-sync-label">loading frames</span>
<span id="cctv-sync-progress">0/0</span>
</div>
<!-- Toast notification -->
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- Safe frame guides for recording composition -->
<div id="safe-frame-overlay">
<div id="safe-frame-box"></div>
</div>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
<div class="cockpit-visor-shell" aria-hidden="true">
<div class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
<span style="--slot:-2;--depth:2">−20</span>
<span style="--slot:-1;--depth:1">−10</span>
<span class="active" style="--slot:0;--depth:0">0</span>
<span style="--slot:1;--depth:1">+10</span>
<span style="--slot:2;--depth:2">+20</span>
<span style="--slot:3;--depth:3">+30</span>
</div>
<div class="cockpit-pitch-rail cockpit-pitch-rail-left">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-pitch-rail cockpit-pitch-rail-right">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-horizon-guide">
<span></span><small>LEVEL</small><span></span>
</div>
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction">
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
</svg>
<small id="cockpit-route-direction-label">DEST ---°</small>
</div>
<div class="cockpit-visor-status cockpit-visor-status-left">OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right">VISOR LOCK · ACTIVE</div>
</div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
<span class="cockpit-altitude-rim-label">GROUND SPEED · KTS</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<span class="material-symbols-outlined">arrow_right</span>
<strong id="cockpit-speed-rim-value">---</strong>
</div>
</div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
<span class="cockpit-altitude-rim-label">ALTITUDE · FT</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<strong id="cockpit-altitude-rim-value">-----</strong>
<span class="material-symbols-outlined">arrow_left</span>
</div>
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker">FIRST PERSON</span>
<strong id="cockpit-callsign">AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta">LIVE TRACK · COURSE ALIGNED</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style">
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
<small>PREV</small>
</button>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next">
<small>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
</button>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style">
<small>NEXT</small>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
</div>
</div>
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<div class="cockpit-compass" aria-label="Current aircraft heading">
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label">GROUND SPEED</span>
<strong id="cockpit-speed-value">---</strong>
<span class="cockpit-readout-unit">KTS</span>
</div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label">ALTITUDE</span>
<strong id="cockpit-altitude-value">-----</strong>
<span class="cockpit-readout-unit">FT</span>
</div>
</div>
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"></div>
</div>
<div class="cockpit-position-readout">
<span id="cockpit-clock">--:--:--Z</span>
<span id="cockpit-position">--° · --°</span>
</div>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden>
<header class="cockpit-context-header">
<div>
<span class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject">CONTACTS · 250 KM</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation">
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window">
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button>
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window">
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button>
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
</nav>
<span class="cockpit-context-qualifier">CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts">
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
<div>
<small>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label">NO AVAILABLE EXAMPLE</strong>
</div>
<span id="cockpit-context-bearing">—</span>
<span id="cockpit-context-distance">—</span>
</div>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
<button
id="cockpit-weather-toggle"
class="cockpit-context-weather"
type="button"
aria-label="Enable cockpit weather effects"
aria-pressed="false"
title="Enable cockpit weather effects"
>
<span>WX</span>
<strong id="cockpit-weather-state">OFF</strong>
</button>
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
</aside>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden>
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden>
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker">ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status">ROUTE DATA UNAVAILABLE</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span>FROM</span>
<strong id="cockpit-route-from">UNKNOWN</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span>TO</span>
<strong id="cockpit-route-to">UNKNOWN</strong>
</div>
</div>
</section>
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
<strong id="cockpit-brief-subtitle">OBSERVED / MAPPED PINGS</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls">
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page">
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button
id="cockpit-brief-auto"
type="button"
aria-label="CYCLE OFF"
aria-pressed="false"
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
>CYCLE OFF</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel">
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</button>
</nav>
</header>
<div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals">
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden>
<div id="cockpit-news-status" class="cockpit-brief-status">ACQUIRING REGIONAL NEWS</div>
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden>
<div class="cockpit-local-place">
<strong id="cockpit-local-place">RESOLVING REGION</strong>
<small id="cockpit-local-coordinates">--</small>
</div>
<div class="cockpit-local-grid">
<div><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
<div><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true">SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News">NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info">LOCAL</button>
</nav>
</footer>
</aside>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls">
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options">◀</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
</div>
</div>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls">◀</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden>
<strong id="cockpit-radio-station" class="cockpit-radio-station">READY</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled>◀◀</button>
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled>▶</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled>▶▶</button>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span>VOLUME</span>
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
</label>
</div>
</div>
</aside>
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
</section>
<nav id="view-switcher" aria-label="View switcher">
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span>RESET</span>
</button>
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden>
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span>EXIT COCKPIT</span>
</button>
</nav>
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">&#x25C0;</button>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label">HUD</span>
</button>
<div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label">Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout">
<option value="tactical">Tactical</option>
<option value="operator">Operator</option>
<option value="minimal">Minimal</option>
</select>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
<span class="pp-icon">&#x25A3;</span>
<span class="pp-label">DETECT</span>
</button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider">Density</label>
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" />
<span class="pp-slider-value" id="detection-density-value">50%</span>
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<span class="pp-slider-mini-label">Allocation</span>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation">
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true">Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false">Weighted</button>
</div>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider">Fade</label>
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" />
<span class="pp-slider-value" id="detection-fade-value">7%</span>
</div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider">Outside</label>
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" />
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
</div>
</div>
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
<div id="param-slider-panel">
<div class="param-panel-header">
<span class="param-panel-title">PARAMETERS</span>
<span class="param-panel-divider"></span>
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel">−</button>
</div>
<div id="param-sliders"></div>
</div>
<div class="pp-toggle-group">
<!-- 3D aircraft is DEFAULT-ON in Proximity (owner directive 2026-08-22), so the
button ships lit and the mode row ships open — the same markup-carries-the-default
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
is what flips them back for a share link or a stored session carrying 3D off. -->
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close">
<span class="pp-icon">&#x2708;</span>
<span class="pp-label">3D</span>
</button>
<div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label">Models</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage">
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true">Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false">All</button>
</div>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label">Scope</span>
</button>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label">Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
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" 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>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
<span class="pp-label">Celestial</span>
</button>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome">
<span class="pp-icon">&#x25A1;</span>
<span class="pp-label">Clean UI</span>
</button>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow">
<span class="pp-icon">&#x2728;</span>
<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" aria-label="Bloom intensity" />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening">
<span class="pp-icon">&#x1F50D;</span>
<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" aria-label="Sharpen intensity" />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
</div>
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls">
<!-- Control panel -->
<div id="control-panel" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false"
aria-controls="control-panel-popover" aria-label="Expand Visual Presets">
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<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" 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" 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" 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" 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" 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" 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" 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>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
<span id="map-source-label">MAP SOURCE</span>
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
</div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
</section>
</div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
<span class="panel-mini-label">Style</span>
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
</div>
</div>
</div>
<!-- Location bar -->
<div id="location-bar" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<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 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>
<div class="location-city-row">
<div id="location-pills" class="location-pills"></div>
<div class="location-search-wrap">
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" />
</div>
</div>
</div>
</div>
</div>
</div>
<div id="left-panel-stack">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div class="panel-glow"></div>
<div class="data-panel-inner">
<div class="panel-header">
<span class="panel-title">DATA LAYERS</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel">+</button>
</div>
<div id="data-toggles" class="data-toggle-list"></div>
</div>
</div>
<!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner">
<div class="panel-header">
<span class="panel-title">CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
</div>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
</div>
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
<div id="cctv-meta">Enable CCTV to load camera intersections</div>
<div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn">CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn">NEAREST</button>
</div>
<div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn">PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera"></select>
<button id="cctv-next-btn" class="scene-btn">NEXT</button>
</div>
<div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn">FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn">COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn">AUTO HOP OFF</button>
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn">PROJECTION ON</button>
<span id="cctv-quality-chip">CAL · --</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label">CALIBRATION</div>
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV">ADJUST</button>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type">
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type">HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type">PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type">FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type">RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type">HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type">ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type">ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" class="scene-btn">SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn">RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label">SCENE SUMMARY</div>
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
<!-- Scene Director Panel -->
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
<div class="scene-panel-inner">
<div class="panel-header">
<span class="panel-title">SCENES</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel">+</button>
</div>
<div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe"></select>
<button id="scene-new-btn" class="scene-btn">NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger">DEL</button>
</div>
<div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn">CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn">UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls">
<button id="scene-start-btn" class="scene-btn">START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger">STOP</button>
<button id="scene-next-btn" class="scene-btn">NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" class="scene-btn">EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn">IMPORT</button>
<button id="scene-download-btn" class="scene-btn">RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">
<div id="scene-progress-fill">0%</div>
</div>
<div id="scene-status">Ready</div>
</div>
</div>
</div>
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail">
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner">
<div class="panel-header">
<span class="panel-title">CONTEXT</span>
<span class="panel-divider"></span>
<div id="context-radio-dock" class="context-radio-dock">
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
</button>
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden>
<div class="context-radio-mini-heading">
<span>RADIO</span>
<strong id="context-radio-mini-station">RADIO READY</strong>
<span class="context-radio-mini-actions">
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
</button>
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</span>
</div>
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<div class="context-radio-mini-transport">
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled>◀◀</button>
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled>▶</button>
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled>▶▶</button>
</div>
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
<span>VOLUME</span>
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled />
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
</label>
</div>
</div>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
</div>
<div class="global-context-modes" role="tablist" aria-label="Context mode">
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
<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="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>
</div>
<div id="context-mode-standby" class="context-mode-standby">
<strong>SELECT CONTEXT</strong>
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
<div class="global-context-actions" aria-label="Contact Context actions">
<button id="cockpit-entry" type="button" hidden>
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span>COCKPIT</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button">SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B">🛸</button>
</div>
<aside id="military-awareness-panel">
<div class="military-awareness-standby">
<strong>CONTACTS CONTEXT OFF</strong>
<span>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
</div>
</aside>
</section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
<div id="space-mission-panel-host">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions">
<div class="space-mission-roster-header">
<div>
<strong>AVAILABLE MISSIONS</strong>
<span>SELECT A MISSION TO INSPECT</span>
</div>
<output data-mission-roster-count>—</output>
</div>
<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>
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
<div class="radio-panel-inner">
<div class="panel-header">
<span class="panel-title">RADIO</span>
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section">+</button>
</div>
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<label class="radio-filter-label" for="radio-filter">STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled>
<option value="all">All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
<strong id="radio-station-name">NO STATION SELECTED</strong>
<span id="radio-station-meta">Enable Radio, then choose a globe marker or use next.</span>
<span id="radio-station-tags"></span>
</div>
<div id="radio-tuner" class="radio-tuner" hidden>
<div class="radio-tuner-header">
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
<output id="radio-tuner-value" for="radio-tuner-slider">DRAG TO TUNE</output>
</div>
<div class="radio-tuner-dial">
<div class="radio-tuner-scale" aria-hidden="true"></div>
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
</div>
<div class="radio-tuner-readout">
<span id="radio-tuner-station">ALL · DRAG THE NEEDLE</span>
<span>SNAPS TO AVAILABLE STATIONS</span>
</div>
</div>
<div class="radio-transport" role="group" aria-label="Radio playback">
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled>PREV</button>
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled>PLAY</button>
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled>NEXT</button>
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled>STOP</button>
</div>
<label class="radio-volume-row" for="radio-volume">
<span>VOLUME</span>
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" />
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
</label>
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite">Radio off</div>
<div class="radio-links">
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden>STATION SITE</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
</div>
</section>
</div>
</div>
</aside>
<div id="scene-runtime"></div>
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
until the visitor asks; shared links skip this surface entirely. It returns
every session until "Don't show this again" is ticked — see
src/firstRunExperience.js for the full show policy. -->
<aside id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" hidden>
<div class="first-run-scanline" aria-hidden="true"></div>
<header class="first-run-header">
<span class="first-run-kicker">MISSION CONTROL · FIRST LAUNCH</span>
</header>
<h2 id="first-run-title">Choose your first view</h2>
<!-- The card's one persuasive line, owner-authored and VERBATIM (2026-08-23)
— including the unspaced em dash. The arc it has to carry: this looks
like something you should not have access to → the sources are all
public → the data is real. Tile subcopy stays purely functional. -->
<p id="first-run-description">It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
<div class="first-run-choices">
<button type="button" data-first-run-choice="contacts">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span><strong>LIVE CONTACTS</strong><small>Aircraft, vessels and nearby intelligence</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="space-missions">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span><strong>SPACE MISSIONS</strong><small>Launches, spacecraft and orbital context</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="environmental">
<span class="material-symbols-outlined" aria-hidden="true">local_fire_department</span>
<!-- Title text is painted from ENVIRONMENTAL_LABEL_CHOICE at init. -->
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
optimizes for the fully configured experience (owner ruling,
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
<span><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" class="first-run-explore" data-first-run-choice="explore">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span><strong>EXPLORE MANUALLY</strong><small>Begin with a clean globe</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
</div>
<div class="first-run-footer">
<label class="first-run-suppress">
<input type="checkbox" data-first-run-suppress />
<span>Don't show this again</span>
</label>
<span>ESC to dismiss</span>
</div>
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
a live region that also holds layout makes the row jump as it speaks. -->
<p class="first-run-note" data-first-run-status role="status" aria-live="polite">Tip: the GEV MIC button in the dock lets you talk to the map.</p>
</aside>
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label>POWER UP</span>
</button>
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
<div class="key-setup-scanline" aria-hidden="true"></div>
<header class="key-setup-header">
<span class="key-setup-kicker">GROUND STATION · PROVIDER SETTINGS</span>
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<h2 id="key-setup-title">Power up the globe</h2>
<p id="key-setup-description">The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
<div class="key-setup-rows" data-key-setup-rows></div>
<div class="key-setup-footer">
<button type="button" class="key-setup-apply" data-key-setup-apply>SAVE KEYS</button>
<span class="key-setup-hint">ESC to close</span>
</div>
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite">The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
</aside>
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<!-- Loading screen -->
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status">Initializing photorealistic world...</p>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2875
View File
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
{
"name": "gods-eye-view",
"private": true,
"version": "0.1.1",
"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",
"homepage": "https://github.com/bilawalsidhu/gods-eye-view#readme",
"repository": {
"type": "git",
"url": "https://github.com/bilawalsidhu/gods-eye-view.git"
},
"bugs": {
"url": "https://github.com/bilawalsidhu/gods-eye-view/issues"
},
"engines": {
"node": ">=24.14.0 <25 || >=26 <27"
},
"keywords": [
"cesium",
"3d-globe",
"geospatial",
"osint",
"spatial-intelligence",
"photorealistic-3d-tiles",
"adsb",
"ais",
"satellites",
"voice-control",
"openai-realtime",
"visualization"
],
"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",
"build": "vite build",
"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",
"check:boundaries": "node scripts/check-package-boundaries.mjs"
},
"dependencies": {
"@mapbox/vector-tile": "^3.0.0",
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
"satellite.js": "^6.0.2"
},
"devDependencies": {
"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"
}
}
+32
View File
@@ -0,0 +1,32 @@
# God's Eye View works without any API keys.
# Easiest way to add one: open PROVIDER SETTINGS inside the running app (the
# POWER UP chip, bottom-right). It saves into this file for you with owner-only
# permissions and restarts the app — you never need to edit this file by hand.
# Manual fallback: use Pinokio's File Explorer to reveal this ignored
# pinokio/ENVIRONMENT file, edit it with a trusted local text editor, uncomment
# only the providers you need, then Stop and Start the app. This file is
# plaintext, not encrypted.
# 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=
# FIRMS_MAP_KEY=
# TOMTOM_API_KEY=
# OPENSKY_CLIENT_ID=
# OPENSKY_CLIENT_SECRET=
# LL2_API_TOKEN=
# Keep sharing off. The current supported Pinokio release logs successful
# tunnel-login passcodes, so the launcher refuses to create a tunnel.
PINOKIO_SHARE_CLOUDFLARE=false
PINOKIO_SHARE_LOCAL=false
PINOKIO_SHARE_VAR=__gev_sharing_disabled__
# App-level guards, not billing caps. Provider-side budgets remain authoritative.
GEV_RATELIMIT_OPENAI_PER_MIN=30
GEV_RATELIMIT_GOOGLE_PER_MIN=120
+36
View File
@@ -0,0 +1,36 @@
module.exports = {
run: [
{
when: "{{!kernel.exists(cwd, 'ENVIRONMENT')}}",
method: 'fs.copy',
params: {
src: '_ENVIRONMENT',
dest: 'ENVIRONMENT',
},
},
{
method: 'shell.run',
params: {
path: '..',
// Forward nonblank app configuration values for Pinokio compatibility. The
// 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 || ""}}',
AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}',
FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}',
TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}',
OPENSKY_CLIENT_ID: '{{env.OPENSKY_CLIENT_ID || ""}}',
OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}',
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
},
message: 'node scripts/pinokio-install.mjs',
},
},
],
};
+3
View File
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
+39
View File
@@ -0,0 +1,39 @@
module.exports = {
version: '3.6',
title: "God's Eye View",
description: 'A live 3D intelligence console for planet Earth.',
menu: async (kernel, info) => {
const installed = await kernel.exists(__dirname, '.installed');
const installing = info.running('install.js');
const starting = info.running('start.js');
const updating = info.running('update.js');
const resetting = info.running('reset.js');
if (installing || updating || resetting) {
const href = installing ? 'install.js' : updating ? 'update.js' : 'reset.js';
const text = installing ? 'Installing' : updating ? 'Updating' : 'Resetting';
return [{ default: true, icon: 'fa-solid fa-terminal', text, href }];
}
if (!installed) {
return [{ default: true, icon: 'fa-solid fa-download', text: 'Install', href: 'install.js' }];
}
if (starting) {
const local = info.local('start.js');
if (local?.url) {
return [
{ default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url },
{ icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' },
];
}
return [{ default: true, icon: 'fa-solid fa-terminal', text: 'Starting', href: 'start.js' }];
}
return [
{ default: true, icon: 'fa-solid fa-power-off', text: 'Start', href: 'start.js' },
{ icon: 'fa-solid fa-arrows-rotate', text: 'Update', href: 'update.js' },
{ icon: 'fa-solid fa-broom', text: 'Repair installation', href: 'reset.js' },
];
},
};
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
run: [
{
method: 'shell.run',
params: {
path: '..',
message: 'node scripts/pinokio-reset.mjs',
},
},
],
};
+43
View File
@@ -0,0 +1,43 @@
module.exports = {
daemon: true,
run: [
{
method: 'shell.run',
params: {
path: '..',
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 || ""}}',
AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}',
FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}',
TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}',
OPENSKY_CLIENT_ID: '{{env.OPENSKY_CLIENT_ID || ""}}',
OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}',
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
PINOKIO_SHARE_CLOUDFLARE: '{{env.PINOKIO_SHARE_CLOUDFLARE || "false"}}',
PINOKIO_SHARE_LOCAL: '{{env.PINOKIO_SHARE_LOCAL || "false"}}',
PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}',
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
},
message: 'node scripts/pinokio-start.mjs',
on: [{
event: '/\\[Pinokio\\] Ready at (http:\\/\\/127\\.0\\.0\\.1:[0-9]+\\/)/',
done: true,
}],
},
},
{
// Pinokio requires local.url for ready/Open state. PINOKIO_SHARE_VAR is
// pinned to a different sentinel so local.set cannot trigger sharing.
method: 'local.set',
params: {
url: '{{input.event[1]}}',
},
},
],
};
+27
View File
@@ -0,0 +1,27 @@
module.exports = {
run: [
{
method: 'shell.run',
params: {
path: '..',
// 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 || ""}}',
AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}',
FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}',
TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}',
OPENSKY_CLIENT_ID: '{{env.OPENSKY_CLIENT_ID || ""}}',
OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}',
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
},
message: 'node scripts/pinokio-update.mjs',
},
},
],
};
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" aria-hidden="true">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="180 120 775 520" role="img" aria-labelledby="logo-title">
<title id="logo-title">God's Eye View</title>
<defs>
<style>
.cls-4 {
fill: #fff;
}
.cls-2, .cls-4 {
stroke: #000;
stroke-width: 1px;
}
.cls-1 {
fill: #4454727a;
stroke: #48b;
stroke-width: 10px;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
fill-rule: evenodd;
}
.cls-2 {
fill: #00f6ff;
}
.cls-3 {
stroke: #00f6ff;
stroke-linecap: round;
stroke-width: 6.99px;
}
.cls-4 {
opacity: 0.63;
}
</style>
</defs>
<path id="outer_eye" class="cls-1" d="M567.5,623c-163.183,0-303.026-99.084-361.823-239.922C257.508,238.76,401.037,135,570,135c166.451,0,308.222,100.7,361.954,241.667C874.771,520.905,733.152,623,567.5,623ZM567,156c124.816,0,226,101.184,226,226S691.816,608,567,608,341,506.816,341,382,442.184,156,567,156ZM816,256.213A272.836,272.836,0,0,1,846,381M793.574,488.677a249.545,249.545,0,0,0,3.239-208.3A303.384,303.384,0,0,0,816,256.213a274.468,274.468,0,0,1-1.516,252.475q-0.927,1.758-1.88,3.5-1.632-2.238-3.306-4.446Q801.826,497.884,793.574,488.677ZM345.5,490.85q-8.055,9.164-15.37,18.966a274.353,274.353,0,0,1-3.748-250.343q7.347,10.012,15.469,19.393A249.512,249.512,0,0,0,345.5,490.85Z"/>
<path id="inner_eye" class="cls-2" d="M568,179.985c107.678,0,194.968,88.655,194.968,198.015S675.678,576.015,568,576.015,373.032,487.36,373.032,378,460.322,179.985,568,179.985Z"/>
<path id="globe" class="cls-3" d="M569.5,499.831A120.331,120.331,0,1,1,689.831,379.5,120.33,120.33,0,0,1,569.5,499.831Zm65.1-50.585c1.225-.4,3.268-2.858,4.492-3.677s3.268-4.083,3.675-5.717,1.227-4.495,1.634-6.126a36.015,36.015,0,0,0,.818-6.135,14.639,14.639,0,0,0-.407-2.855l-3.266,6.127s-4.9,2.04-5.315,3.674-0.817,4.088-.817,4.088a61.248,61.248,0,0,0-2.45,6.133C632.554,446.388,633.372,449.66,634.6,449.246ZM461.011,377.072c0.72-1.105,1.928-6.276,1.6-7.894-0.411-2.037,1.225-6.94,1.225-9.392s4.083-7.358,4.9-9.4,4.491-7.349,6.535-10.623-0.4-2.038-2.449-.809,1.225-3.274,2.449-4.086,8.172-5.72,8.172-5.72,2.449-2.454-.409-7.762c-2.211-4.1,0-4.5,0-6.128a2.43,2.43,0,0,0-1.28-.223c2.129-2.952,4.408-5.788,6.811-8.512A108.871,108.871,0,0,0,494.612,458.6a0.454,0.454,0,0,1,.683-0.363c2.043,0.819,2.449-.4,4.085-0.4s4.086-2.043,4.086-2.043l-1.641-4.9,1.641-6.126,2.041-5.717s0-4.095-2.041-4.5-5.722-4.081-6.946-4.081a10.018,10.018,0,0,1-4.491-1.636c-1.227-.813-3.677-1.634-4.5-2.857a31.144,31.144,0,0,1-2.858-5.314c-0.816-2.039-.409-2.039-3.681-5.715s-2.452-2.863-4.9-4.093-3.265-2.443-5.308-3.263a13.378,13.378,0,0,1-4.5-2.857,4.884,4.884,0,0,0-2.711-1.519,110.041,110.041,0,0,1-2.595-23.808C460.98,378.618,461,377.84,461.011,377.072Zm35.743-78.7a13.8,13.8,0,0,0-1.051,2.589c-0.408,1.638,2.864.416,2.864,0.416L502.239,303l4.5-1.631a13.741,13.741,0,0,1,4.083-4.5c2.857-2.043,2.454-2.452,3.677-4.084s0-.006,5.314-5.732,8.167-5.718,8.167-5.718,1.915-2.1,3.244-3.895A108.986,108.986,0,0,0,496.754,298.368Zm41.024,56.1c-1.224.82-5.715,3.272-6.944,5.315a22.681,22.681,0,0,0-2.859,6.535,31.042,31.042,0,0,0-.408,6.538c0,2.854-.41,4.5,0,6.949s4.5,8.568,6.128,10.623c1.63,2.038,3.674,4.483,5.719,5.713a47.235,47.235,0,0,1,4.9,3.674,4.03,4.03,0,0,0,4.9,1.223c3.272-1.223,6.538-1.223,9.4-2.853s7.353-3.685,7.353-1.231,1.632,4.084,4.082,3.272,6.539-2.858,6.13,0S577,406.764,577,408.4s-0.4,3.265.818,4.495,4.5,3.265,4.5,4.49,0.409,5.311,1.229,6.946,1.639,4.08.823,5.724-2.434,6.123-2.434,6.123-0.779,3.676-.37,4.9a12.683,12.683,0,0,0,3.332,4.906c2.043,2.042,1.771,4.492,2.994,6.124s2.993,6.539,2.993,6.539l1.236,4.088v3.677s7.966-1.231,9.591-1.64a30.673,30.673,0,0,0,8.618-4.9c1.636-1.632,1.311-2.856,3.349-4.488s5.762-4.5,5.762-4.5l-0.387-6.126,4.914-6.544,3.684-3.677s3.267-4.484,2.865-6.128c-0.413-1.628-1.231-9.8-1.231-9.8a37.972,37.972,0,0,1,2.863-5.313c1.224-1.634,1.227-3.678,3.679-5.719a33.109,33.109,0,0,0,5.306-6.126c1.229-1.634,3.681-4.9,4.5-6.127a21.871,21.871,0,0,0,1.635-4.894l-7.355,1.216c-2.45-4.082-9.392-13.073-10.621-15.112a37.04,37.04,0,0,1-2.861-7.76c-0.4-1.634-2.445-6.131-2.856-7.356s-3.27-4.908-4.088-6.128-3.675-2.043-5.309-3.268-11.843-3.27-11.843-3.27-3.129-1.991-4.088-1.226c-4.083,3.269-13.48,1.226-15.523.415s-3.269-2.041-3.269-3.272-1.835-7.143-1.835-7.143-3.985.705-5.213,0.705a20.333,20.333,0,0,1-4.442-1.276s-4.055.379-5.693,1.2a61.205,61.205,0,0,1-6.115,2.438,36.488,36.488,0,0,1-4.488.814c-3.671-1.636-6.529,2.45-7.352,4.079s-2.445,2.454-2.445,4.084S539,353.659,537.778,354.471Zm18.191-54.739-3.128-4.5-3.727,2.045V304.5l-3.839-1.5-1.715,3.674,1.685,2.452L549.114,305v4.129l8.478-1.636,3.164-2.449Zm71.15-12.676c-1.669.869-4.808,3.094-6,1.894-1.227-1.218-4.086-3.423-4.086-3.423,2.448-1.636-4.9-5.212-4.9-5.212l-8.576-1.022h-6.125c-1.227,0-3.681.825-5.723,0.825s-6.943,3.583-8.577,3.994-5.314,2.5-6.944,2.5-4.088,3.086-4.088,3.086-2.856,5.218-.4,4.4,5.717-.858,6.122.367,1.227,3.242,1.636,4.471,6.131,1.618,6.131,1.618a18.551,18.551,0,0,1,2.038-3.683c1.227-1.634,2.452-3.675,2.452-3.675s2.044,4.082,2.452,5.3-0.818,4.908-.818,4.908-4.491,2.852-5.716,2.852-8.175-6.126-8.175-6.126a25.4,25.4,0,0,0-4.489,2.043c-2.856,2.861-5.717,5.31-5.717,5.31a17.057,17.057,0,0,0-4.081,2.046c-1.629,1.222-5.138,1.487-6.117,2.854-2.039,2.863.84,4.9,0.84,4.9l-2.408,3.271-1.548,1.225-5.553,1.221-2.751.616c0.4,1.227,1.258,5.105,1.258,5.105s-1.87,6.943,0,6.943c0,0,4.276,1.64,5.5,1.64s2.625,0.4,4.265-1.227a59.861,59.861,0,0,0,5.156-6.538,8.047,8.047,0,0,1,4.49-3.267c2.454-.82,3-2.047,4.231-2.452s6.47-1.229,6.47-1.229,3.709,2.043,4.932,2.861c0.573,0.386-1.393,1.658,6.086,2.907V331.6l3.7-.411-4.207-3.264-0.44-.1-4.778-4.8-0.419-3.672s4.861,2.862,6.493,3.672a43.671,43.671,0,0,1,4.81,3.272l1.783,5.719,2.4,4.5s-1.87-5.722,0-5.722c0,0,5.158.405,6.381,0a36.253,36.253,0,0,0,5.237-1.632c1.636-.815-0.648-3.68.986-5.721a10.385,10.385,0,0,1,4.981-3.27c1.23-.405,5.35-2.447,5.35-2.447l4.522,0.82c-2.041,1.627-4.486,4.077-2.85,3.676s5.313,1.63,6.949,2.855,0.413,4.087.413,4.087-4.5-.411-7.355-0.815a29.263,29.263,0,0,0-7.351.4l-3.677,2.043s-2.454.816-2.045,2.861,2.045,4.5,3.679,6.535,2.858,4.5,4.085,3.675,5.309-3.265,5.309-3.265a12.784,12.784,0,0,0-.409,4.9,30.6,30.6,0,0,1,.409,4.9s-4.082,0-2.861.822,2.967,3.905,3.681,4.9c4.083,5.717,12.664,18.383,13.482,20.015s0.44,4.126,1.631,5.31c2.454,2.45,7.352,0,8.578-.817s5.311-2.863,6.536-4.082,0.818-.822,3.272-2.454,4.079-2.856,2.859-4.085-3.68-5.725-3.68-5.725c-3.676.825-7.351-1.222-7.351-2.445s-3.269-6.124-3.269-6.124,0.407-.82,3.269.809,5.306,4.908Line truncated
<path id="globe_cage" class="cls-4" d="M569.5,576.491c-106.968,0-193.988-89.044-193.988-198.491S462.532,179.509,569.5,179.509,763.488,268.553,763.488,378,676.468,576.491,569.5,576.491ZM578.4,368.9h76.237a314.2,314.2,0,0,0-11.945-78.026H578.4V368.9ZM654.634,387.1H578.4V464.94h64.345A314.251,314.251,0,0,0,654.634,387.1Zm90.813,0H671.861a333.4,333.4,0,0,1-11.206,77.836h63.112A182.117,182.117,0,0,0,745.447,387.1Zm-32.528,95.444H655.363a288.421,288.421,0,0,1-32.3,67.191A177.214,177.214,0,0,0,712.919,482.548Zm-115.67,73.48a263.416,263.416,0,0,0,39.828-73.48H578.4v75.476A173.846,173.846,0,0,0,597.249,556.028ZM560.6,558.036V482.548H497.674a263.837,263.837,0,0,0,39.135,72.575A174.279,174.279,0,0,0,560.6,558.036ZM510.434,547.83a289.038,289.038,0,0,1-31.078-65.282H426.1A177.5,177.5,0,0,0,510.434,547.83Zm-95.208-82.89H474.1A333.353,333.353,0,0,1,462.878,387.1H393.555A182.127,182.127,0,0,0,415.226,464.94ZM393.555,368.9h69.323a333.4,333.4,0,0,1,11.268-78.026H415.332A182.13,182.13,0,0,0,393.555,368.9Zm32.676-95.633h53.185a289.064,289.064,0,0,1,31-65.087A177.487,177.487,0,0,0,426.231,273.263Zm134.372-75.3a174.068,174.068,0,0,0-23.8,2.914,263.8,263.8,0,0,0-39.06,72.384H560.6v-75.3Zm0,92.905H492.051A314.088,314.088,0,0,0,480.106,368.9h80.5V290.87Zm-80.5,96.234A314.1,314.1,0,0,0,492,464.94h68.6V387.1h-80.5ZM578.4,197.977v75.286h58.612a263.412,263.412,0,0,0-39.751-73.289A174.011,174.011,0,0,0,578.4,197.977Zm44.672,8.286a288.6,288.6,0,0,1,32.231,67h57.491A177.213,177.213,0,0,0,623.069,206.263Zm100.6,84.607H660.6A333.3,333.3,0,0,1,671.861,368.9h73.586A182.129,182.129,0,0,0,723.667,290.87Z"/>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">Microphone</title>
<g fill="none" stroke="#36dcff" stroke-linecap="round" stroke-linejoin="round">
<rect x="23" y="10" width="18" height="30" rx="9" stroke-width="3"/>
<path d="M16 31v2c0 9 7 16 16 16s16-7 16-16v-2" stroke-width="3"/>
<path d="M32 49v8M24 57h16" stroke-width="3"/>
<path d="M28 17h8M28 23h8M28 29h8" stroke-width="2" opacity=".65"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 498 B

+21
View File
@@ -0,0 +1,21 @@
# Bundled 3D Model Attribution
The model files in this directory are third-party visual assets. They are not
covered by the repository's MIT source-code license; each remains available
under the license listed below.
| File | Original work and creator | Source | License | Project modifications |
|---|---|---|---|---|
| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. |
| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. |
| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. |
| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
| `mq9.glb` | “MQ-9” by [IProZenoN](https://sketchfab.com/IProZenoN) | [Sketchfab model](https://sketchfab.com/3d-models/mq-9-fabe963feb354c5584b51f9c470c3f7e) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
| `b789.glb` | “Boeing 787-9” by [Nobilis 2](https://sketchfab.com/nobilishornet2) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-787-9-b6711e2e698e4e469675c1154a50b7a3) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
| `atr72.glb` | “ATR 72 - 600” by [Oyan3D](https://sketchfab.com/oyan3D) | [Sketchfab model](https://sketchfab.com/3d-models/atr-72-600-1e1a7186f7444d288675262fcee44744) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures removed with dominant material colors baked into PBR factors (flat “abstracted” style), orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
CC BY 4.0 permits sharing and adaptation, including commercial use, provided
appropriate credit is retained, the license is linked, and modifications are
identified. These credits do not imply endorsement by the original creators.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" role="img" aria-labelledby="title">
<title id="title">Pin panel</title>
<line x1="12" y1="17" x2="12" y2="22"/>
<path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.12-2.65A2 2 0 0 1 16 10.11V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v5.11a2 2 0 0 1-.44 1.24L5.44 14a2 2 0 0 0-.44 1.24Z"/>
</svg>

After

Width:  |  Height:  |  Size: 489 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 16.5L12 10L21 16.5"/>
<path d="M3 11L12 4.5L21 11"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
/**
* Build src/data/local_data/neighborhoods/san-francisco.json from the official
* public-domain DataSF "Analysis Neighborhoods" dataset.
*
* Source (map view): https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h
* Underlying dataset: https://data.sfgov.org/d/j2bu-swwd (columns: the_geom, nhood)
* Fetched from: https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100
* License: Open Data Commons Public Domain Dedication and License (PDDL 1.0)
* (dataset metadata `licenseId: "PDDL"` at
* https://data.sfgov.org/api/views/j2bu-swwd.json)
*
* Transform (deterministic):
* 1. `properties.nhood` → `properties.name` (no renames — DataSF names kept verbatim).
* 2. Douglas-Peucker simplification per ring, tolerance 2e-5° (~2 m) — shaves
* hyper-detailed shoreline vertices without visibly moving boundaries.
* 3. Coordinates rounded to 6 decimals (~0.1 m); consecutive duplicates dropped;
* rings re-closed; rings that collapse below 4 points dropped.
* 4. Single-part MultiPolygons collapsed to Polygon.
* 5. Features sorted by name for a stable diff.
*
* Usage:
* node scripts/build-sf-neighborhoods.mjs [raw.geojson]
* With no argument it downloads the live dataset; with an argument it reads the
* given raw GeoJSON file (the exact bytes retrieved on 2026-07-30 in our case).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SOURCE_URL = 'https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100';
const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)),
'..', 'src', 'data', 'local_data', 'neighborhoods', 'san-francisco.json');
const TOLERANCE = 2e-5; // degrees, ~2 m
const DECIMALS = 6;
/** Perpendicular distance from point p to segment a-b (in degrees, planar — fine at this scale). */
function segDist(p, a, b) {
let [x, y] = p; const [x1, y1] = a; const [x2, y2] = b;
let dx = x2 - x1; let dy = y2 - y1;
if (dx !== 0 || dy !== 0) {
const t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
if (t > 1) { x -= x2; y -= y2; return Math.hypot(x, y); }
if (t > 0) { x -= x1 + dx * t; y -= y1 + dy * t; return Math.hypot(x, y); }
}
return Math.hypot(x - x1, y - y1);
}
/** Iterative Douglas-Peucker on an open point list. */
function douglasPeucker(points, tolerance) {
if (points.length <= 2) return points.slice();
const keep = new Uint8Array(points.length);
keep[0] = keep[points.length - 1] = 1;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [first, last] = stack.pop();
let maxDist = 0; let index = -1;
for (let i = first + 1; i < last; i++) {
const d = segDist(points[i], points[first], points[last]);
if (d > maxDist) { maxDist = d; index = i; }
}
if (maxDist > tolerance && index !== -1) {
keep[index] = 1;
stack.push([first, index], [index, last]);
}
}
return points.filter((_, i) => keep[i]);
}
const round = (v) => Number(v.toFixed(DECIMALS));
/** Simplify + round one ring; returns null if it degenerates. */
function processRing(ring) {
// GeoJSON rings are closed (first == last); simplify the open part.
const open = ring.length > 1
&& ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]
? ring.slice(0, -1) : ring.slice();
const simplified = douglasPeucker(open, TOLERANCE).map(([lon, lat]) => [round(lon), round(lat)]);
const out = [];
for (const pt of simplified) {
const prev = out[out.length - 1];
if (prev && prev[0] === pt[0] && prev[1] === pt[1]) continue;
out.push(pt);
}
if (out.length < 3) return null;
out.push([out[0][0], out[0][1]]); // re-close
return out;
}
async function main() {
let rawText;
if (process.argv[2]) {
rawText = fs.readFileSync(process.argv[2], 'utf8');
console.log(`read ${process.argv[2]}`);
} else {
console.log(`fetching ${SOURCE_URL}`);
const res = await fetch(SOURCE_URL);
if (!res.ok) throw new Error(`HTTP ${res.status} from DataSF`);
rawText = await res.text();
}
const raw = JSON.parse(rawText);
if (raw.type !== 'FeatureCollection' || !Array.isArray(raw.features)) {
throw new Error('unexpected payload: not a FeatureCollection');
}
let inVerts = 0; let outVerts = 0;
const features = [];
for (const f of raw.features) {
const name = f.properties && f.properties.nhood;
if (!name || !f.geometry) throw new Error(`feature missing nhood/geometry: ${JSON.stringify(f.properties)}`);
const polysIn = f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates
: f.geometry.type === 'Polygon' ? [f.geometry.coordinates]
: null;
if (!polysIn) throw new Error(`unexpected geometry type ${f.geometry.type} for ${name}`);
const polys = [];
for (const poly of polysIn) {
const rings = [];
for (const ring of poly) {
inVerts += ring.length;
const r = processRing(ring);
if (r) { rings.push(r); outVerts += r.length; }
}
if (rings.length && poly[0] && rings[0]) polys.push(rings);
}
if (!polys.length) throw new Error(`geometry collapsed for ${name}`);
const geometry = polys.length === 1
? { type: 'Polygon', coordinates: polys[0] }
: { type: 'MultiPolygon', coordinates: polys };
features.push({ type: 'Feature', properties: { name }, geometry });
}
features.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
const out = {
type: 'FeatureCollection',
city: 'San Francisco',
source: 'DataSF "Analysis Neighborhoods" (dataset j2bu-swwd, map view p5b7-5n3h) — PDDL 1.0 (public domain); see SOURCE.md',
features,
};
fs.writeFileSync(OUT, `${JSON.stringify(out)}\n`);
const kb = (fs.statSync(OUT).size / 1024).toFixed(0);
console.log(`wrote ${OUT}: ${features.length} features, ${inVerts} → ${outVerts} vertices, ${kb} KB`);
}
main().catch((err) => { console.error(err); process.exit(1); });
+146
View File
@@ -0,0 +1,146 @@
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { build, normalizePath } from 'vite';
/** Build each browser or Node export group and reject imports outside its declared ownership. */
export async function checkPackageBoundaries(root) {
root = await realpath(root);
const readJson = async (name) =>
JSON.parse(await readFile(path.join(root, name), 'utf8'));
const pkg = await readJson('package.json');
const groups = await readJson('scripts/package-boundaries.json');
const declaredExports = Object.keys(pkg.exports || {}).sort();
const classifiedExports = Object.values(groups)
.flatMap((group) => group.exports)
.sort();
if (
!declaredExports.length ||
JSON.stringify(declaredExports) !== JSON.stringify(classifiedExports)
) {
throw new Error(
'Every package export must belong to exactly one boundary group',
);
}
const reports = [];
for (const [name, group] of Object.entries(groups)) {
const node = group.runtime === 'node';
if (group.runtime && !['browser', 'node'].includes(group.runtime)) {
throw new Error(`Invalid boundary runtime: ${name}`);
}
if (
!Array.isArray(group.modules) ||
!group.modules.length ||
!Array.isArray(group.external)
) {
throw new Error(`Invalid package boundary: ${name}`);
}
const allowed = new Set();
for (const module of group.modules) {
if (
typeof module !== 'string' ||
path.isAbsolute(module) ||
module.includes('\\') ||
module.split('/').includes('..')
) {
throw new Error(`Boundary modules must be repository paths: ${name}`);
}
const resolved = await realpath(path.join(root, module));
const relative = path.relative(root, resolved);
if (
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new Error(`Boundary module escapes repository: ${name}`);
}
allowed.add(normalizePath(resolved));
}
for (const external of group.external) {
if (
!Object.hasOwn(pkg.dependencies || {}, external) &&
!Object.hasOwn(pkg.peerDependencies || {}, external) &&
!(node && Object.hasOwn(pkg.devDependencies || {}, external))
) {
throw new Error(
`Boundary external must be a declared dependency for its runtime: ${external}`,
);
}
}
const input = group.exports.map((key) => {
const declaration = pkg.exports[key];
const target = node ? declaration?.node : declaration;
if (
node &&
(typeof declaration !== 'object' ||
Object.keys(declaration).join() !== 'node')
) {
throw new Error(`Node export must have only a node condition: ${key}`);
}
if (
typeof target !== 'string' ||
!target.startsWith('./') ||
!allowed.has(normalizePath(path.resolve(root, target)))
) {
throw new Error(`Export must point to an owned module: ${key}`);
}
return path.resolve(root, target);
});
const seen = new Set();
await build({
root,
configFile: false,
envFile: false,
publicDir: false,
ssr: node ? { noExternal: true } : undefined,
logLevel: 'silent',
plugins: [
{
name: 'check-package-ownership',
moduleParsed(info) {
if (!allowed.has(info.id)) {
throw new Error(
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
);
}
seen.add(info.id);
},
},
],
build: {
ssr: node,
lib: { entry: input, formats: ['es'] },
write: false,
minify: false,
assetsInlineLimit: 0,
rollupOptions: {
input,
external: group.external,
// Unused imports must still obey ownership; tree shaking is not a boundary.
treeshake: false,
preserveEntrySignatures: 'strict',
},
},
});
reports.push({ name, exports: input.length, modules: seen.size });
}
return reports;
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) {
try {
const reports = await checkPackageBoundaries(
fileURLToPath(new URL('../', import.meta.url)),
);
for (const report of reports)
console.log(
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`,
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 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}"
exec bash "$ROOT_DIR/scripts/dev-fresh.sh"
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PORT="${PORT:-4173}"
# Local-only by default: the dev server brokers configured API keys, so it
# should not be reachable from the network unless explicitly requested.
# Set HOST=0.0.0.0 to opt in to LAN exposure (a warning is printed).
HOST="${HOST:-localhost}"
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
# pack; override per-run for lighter/heavier loads. Kill switches:
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
# is preserved rather than replaced by the default. Still set-u-safe when unset.
CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
# Capture which provider credentials genuinely came from the parent shell
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
# passed to Vite; values never enter the provenance marker. This lets Provider
# Settings keep an exported credential read-only even when .env happens to hold
# 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)
[[ -n "${FIRMS_MAP_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(FIRMS_MAP_KEY)
[[ -n "${TOMTOM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(TOMTOM_API_KEY)
[[ -n "${OPENSKY_CLIENT_ID:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENSKY_CLIENT_ID)
[[ -n "${OPENSKY_CLIENT_SECRET:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENSKY_CLIENT_SECRET)
[[ -n "${LL2_API_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(LL2_API_TOKEN)
KEY_SETUP_EXTERNAL_KEYS_CSV="$(IFS=,; printf '%s' "${KEY_SETUP_EXTERNAL_KEYS[*]:-}")"
if command -v npm >/dev/null 2>&1; then
DEV_COMMAND=(npm run dev --)
elif command -v pnpm >/dev/null 2>&1; then
DEV_COMMAND=(pnpm run dev)
else
echo "error: neither npm nor pnpm found"
exit 1
fi
read_dotenv_value() {
local variable_name="$1"
if ! command -v node >/dev/null 2>&1; then
echo "warning: node not found; cannot parse dotenv files" >&2
return
fi
node scripts/read-dotenv-value.mjs "${variable_name}"
}
# Vite loads .env for browser build-time configuration, but this launcher needs
# the Maps key before Vite starts. Preserve a shell-provided value; otherwise
# read Vite's project-local dotenv ladder without executing it as shell code.
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
GOOGLE_MAPS_API_KEY_ENV_SOURCE="env"
if [[ -z "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
GOOGLE_MAPS_API_KEY_ENV="$(read_dotenv_value "GOOGLE_MAPS_API_KEY")"
GOOGLE_MAPS_API_KEY_ENV_SOURCE="dotenv"
fi
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_ENV}" ]]; then
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
GOOGLE_MAPS_API_KEY_SOURCE="${GOOGLE_MAPS_API_KEY_ENV_SOURCE}"
elif [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
else
GOOGLE_MAPS_API_KEY=""
fi
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
GOOGLE_MAPS_API_KEY_SOURCE="not configured"
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="${OPENSKY_AUTH_MODE:-$(read_dotenv_value "OPENSKY_AUTH_MODE")}"
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-$(read_dotenv_value "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
# Explicit env wins, then .env, then the credentials file / Keychain below.
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-$(read_dotenv_value "OPENSKY_CLIENT_ID")}"
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-$(read_dotenv_value "OPENSKY_CLIENT_SECRET")}"
OPENSKY_USERNAME="${OPENSKY_USERNAME:-$(read_dotenv_value "OPENSKY_USERNAME")}"
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-$(read_dotenv_value "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
# Optional keys: explicit env wins, followed by .env and Keychain fallback.
# Add to Keychain with e.g.:
# security add-generic-password -U -s "openai-api" -a "api-key" -w
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_dotenv_value "OPENAI_API_KEY")}"
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_dotenv_value "AISSTREAM_API_KEY")}"
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_dotenv_value "CESIUM_ION_TOKEN")}"
LL2_API_TOKEN="${LL2_API_TOKEN:-$(read_dotenv_value "LL2_API_TOKEN")}"
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_dotenv_value "TOMTOM_API_KEY")}"
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_dotenv_value "FIRMS_MAP_KEY")}"
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_keychain_secret "openai-api" "api-key")}"
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_keychain_secret "aisstream-api" "api-key")}"
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_keychain_secret "cesium-ion" "token")}"
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_keychain_secret "tomtom-api" "api-key")}"
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_keychain_secret "firms-map" "map-key")}"
if [[ ! -f "src/data/cctv.js" ]]; then
echo "error: expected CCTV layer file missing: src/data/cctv.js"
exit 1
fi
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
echo "Stopping all existing God's Eye View dev servers..."
pkill -f "${ROOT_DIR}/node_modules/.bin/vite" >/dev/null 2>&1 || true
pkill -f "${ROOT_DIR}/node_modules/vite/bin/vite.js" >/dev/null 2>&1 || true
# Also clear the requested port in case it is held by a stale wrapper or a
# server started through a different package-manager command.
if command -v lsof >/dev/null 2>&1; then
PIDS="$(lsof -tiTCP:${PORT} -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "${PIDS}" ]]; then
echo "${PIDS}" | xargs kill -9 >/dev/null 2>&1 || true
fi
fi
echo "Clearing Vite cache..."
rm -rf node_modules/.vite
echo "Starting fresh God's Eye View dev server..."
case "${HOST}" in
localhost|127.0.0.1|::1)
echo "Local-only mode: reachable at http://localhost:${PORT}/ (set HOST=0.0.0.0 for LAN)"
;;
*)
LAN_IP=""
if command -v ipconfig >/dev/null 2>&1; then
# macOS: first active interface wins
for iface in en0 en1; do
LAN_IP="$(ipconfig getifaddr "${iface}" 2>/dev/null || true)"
[[ -n "${LAN_IP}" ]] && break
done
elif command -v hostname >/dev/null 2>&1; then
# Linux: hostname -I lists addresses; take the first
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
fi
echo ""
echo "!! =============================================================="
echo "!! WARNING: HOST=${HOST} — network-exposed mode."
echo "!! This dev server brokers your configured API keys (OpenAI,"
echo "!! OpenSky, AISStream, TomTom, FIRMS, LL2, Google) to ANYONE who can"
echo "!! reach it on the network. Use only on networks you trust."
echo "!! Consider the opt-in per-IP throttles GEV_RATELIMIT_OPENAI_PER_MIN"
echo "!! and GEV_RATELIMIT_GOOGLE_PER_MIN (see .env.example) — and note"
echo "!! they are NOT billing caps; set provider-side budget alerts too."
if [[ -n "${LAN_IP}" ]]; then
echo "!! LAN URL: http://${LAN_IP}:${PORT}/"
else
echo "!! LAN URL: http://<this-machine-ip>:${PORT}/"
fi
echo "!! =============================================================="
echo ""
echo "URL (this machine): http://localhost:${PORT}/"
;;
esac
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)."
echo "If panels are still missing, run this once in browser console:"
echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();"
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
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC disabled"
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
if [[ -n "${GOOGLE_MAPS_API_KEY}" ]]; then
echo "Startup map: Google Photorealistic 3D Tiles (direct)"
elif [[ -n "${CESIUM_ION_TOKEN}" ]]; then
echo "Startup map: Google Photorealistic 3D Tiles (Cesium ion)"
else
echo "Startup map: Esri World Imagery with keyless terrain (OpenStreetMap fallback)"
fi
[[ -n "${CESIUM_ION_TOKEN}" ]] && echo "Cesium ion token: configured — Google 3D, Bing, and world-terrain stacks available" || echo "Cesium ion token: not set"
[[ -n "${TOMTOM_API_KEY}" ]] && echo "TomTom key (live traffic flow): configured" || echo "TomTom key (live traffic flow): not set — simulated traffic"
[[ -n "${FIRMS_MAP_KEY}" ]] && echo "NASA FIRMS key (live fires): configured" || echo "NASA FIRMS key (live fires): not set — fires layer requires a key"
[[ -n "${LL2_API_TOKEN}" ]] && echo "Launch Library 2 token: configured" || echo "Launch Library 2 token: not set — using public access"
# Build the dev server environment explicitly. A value that resolved to
# nothing is left UNSET instead of being exported empty: Vite backfills its
# own .env values only for variables it finds undefined, so an empty export
# would shadow a key the user did configure in .env.
DEV_ENV=()
DEV_UNSET=()
put_env() {
DEV_ENV+=("$1=$2")
}
put_env_if_set() {
if [[ -n "$2" ]]; then
DEV_ENV+=("$1=$2")
else
# Leaving it out is not enough: the child inherits this shell's
# environment, so an empty export made in the PARENT would pass straight
# through and shadow .env just the same. Remove it from the child outright.
DEV_UNSET+=(-u "$1")
fi
}
put_env_if_set GOOGLE_MAPS_API_KEY "${GOOGLE_MAPS_API_KEY}"
put_env CCTV_AUSTIN_MAX_SOURCES "${CCTV_AUSTIN_MAX_SOURCES}"
# Empty is the documented Caltrans kill switch, so this one is passed as-is.
put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}"
put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
put_env_if_set OPENSKY_CLIENT_ID "${OPENSKY_CLIENT_ID}"
put_env_if_set OPENSKY_CLIENT_SECRET "${OPENSKY_CLIENT_SECRET}"
put_env_if_set OPENSKY_USERNAME "${OPENSKY_USERNAME}"
put_env_if_set OPENSKY_PASSWORD "${OPENSKY_PASSWORD}"
put_env_if_set OPENAI_API_KEY "${OPENAI_API_KEY}"
put_env_if_set AISSTREAM_API_KEY "${AISSTREAM_API_KEY}"
put_env_if_set CESIUM_ION_TOKEN "${CESIUM_ION_TOKEN}"
put_env_if_set TOMTOM_API_KEY "${TOMTOM_API_KEY}"
put_env_if_set FIRMS_MAP_KEY "${FIRMS_MAP_KEY}"
put_env_if_set LL2_API_TOKEN "${LL2_API_TOKEN}"
put_env GEV_LAUNCHER "dev-fresh"
put_env GEV_KEY_SETUP_EXTERNAL_KEYS "${KEY_SETUP_EXTERNAL_KEYS_CSV}"
env ${DEV_UNSET[@]+"${DEV_UNSET[@]}"} "${DEV_ENV[@]}" "${DEV_COMMAND[@]}" --host "${HOST}" --port "${PORT}" --force
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PORT="${PORT:-4173}"
# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN.
HOST="${HOST:-localhost}"
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
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
echo "Starting God's Eye View dev server..."
echo "URL: http://localhost:${PORT}/"
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
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_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}"
+7
View File
@@ -0,0 +1,7 @@
# Voice QA fixture
`full-globe-turn-on-radio.wav` is an owner-generated fake-microphone fixture for the credentialed AI-to-Radio acceptance test. Its embedded WAV metadata is the provenance record for the generator, model, voice, spoken text, usage terms, and audio settings; inspect it with `ffprobe -show_entries format_tags -of json scripts/fixtures/voice/full-globe-turn-on-radio.wav`.
- Provided and approved for repository QA use by the user on 2026-08-05.
- SHA-256: `b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb`
- Run: `node scripts/qa-voice-wav.mjs http://localhost:4189`
Binary file not shown.
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import {
advanceSpriteFocus,
} from '../src/data/focusDeemphasis.js';
import {
applyAircraftBillboardTreatment,
} from '../src/data/aircraftRecession.js';
if (typeof global.gc !== 'function') {
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
}
const CALLS = 100_000;
const TRIALS = 9;
const WARMUP_CALLS = 200_000;
const PURE_CALL_BUDGET = 16;
const PRODUCTION_SPRITE_TICK_BUDGET = 212;
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
function bytesPerCall(fn) {
for (let index = 0; index < WARMUP_CALLS; index += 1) fn();
const samples = [];
for (let trial = 0; trial < TRIALS; trial += 1) {
global.gc();
const before = process.memoryUsage().heapUsed;
let checksum = 0;
for (let index = 0; index < CALLS; index += 1) checksum += fn();
const after = process.memoryUsage().heapUsed;
// Keep the returned scalar observable so V8 cannot delete the calls.
assert.ok(Number.isFinite(checksum));
samples.push((after - before) / CALLS);
}
const rawMedian = median(samples);
return { rawMedian, samples };
}
function netOfLoopBaseline(observed, baseline) {
// V8's callback/checksum loop itself retains roughly one boxed-number word
// per call until the next collection. Subtract the identically measured
// no-op loop so this gate reports allocation attributable to the treatment.
const rawMedian = Math.max(0, observed.rawMedian - baseline.rawMedian);
return {
rawMedian,
roundedMedian: Math.round(rawMedian),
observedRawMedian: observed.rawMedian,
baselineRawMedian: baseline.rawMedian,
samples: observed.samples,
};
}
const target = {
screenRect: { left: 0, top: 0, right: 10, bottom: 10 },
paddingPx: 18,
cameraDistance: 1000,
};
const sprite = {};
const focusInput = {
screenPosition: { x: 5, y: 5 },
cameraDistance: 1200,
nowMs: 1000,
target,
};
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
const billboard = { scale: 1, color: { alpha: 1 } };
const aircraftInput = {
billboard,
baseScale: 1,
baseAlpha: 1,
baseColor,
focusFactor: 1,
cameraDistanceM: 100,
cameraHeightM: 1000,
};
const baseline = bytesPerCall(() => focusInput.nowMs);
const focus = netOfLoopBaseline(
bytesPerCall(() => advanceSpriteFocus(sprite, focusInput).factor),
baseline,
);
const aircraft = netOfLoopBaseline(
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
baseline,
);
const productionSpriteTick = netOfLoopBaseline(
bytesPerCall(() => {
// Match the hot civilian/military sprite path: the caller constructs fresh
// option literals, consumes the shared focus result immediately, and then
// constructs the composed aircraft-treatment options for the same sprite.
const focusResult = advanceSpriteFocus(sprite, {
screenPosition: focusInput.screenPosition,
cameraDistance: focusInput.cameraDistance,
nowMs: focusInput.nowMs,
target,
});
const treatment = applyAircraftBillboardTreatment({
billboard,
baseScale: aircraftInput.baseScale,
baseAlpha: aircraftInput.baseAlpha,
baseColor,
focusFactor: focusResult.factor,
cameraDistanceM: aircraftInput.cameraDistanceM,
cameraHeightM: aircraftInput.cameraHeightM,
});
return focusResult.factor + treatment.alpha;
}),
baseline,
);
assert.ok(
focus.roundedMedian <= PURE_CALL_BUDGET,
`advanceSpriteFocus allocated ${focus.rawMedian.toFixed(3)} B/call median`,
);
assert.ok(
aircraft.roundedMedian <= PURE_CALL_BUDGET,
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
);
assert.ok(
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
);
console.log(JSON.stringify({
callsPerTrial: CALLS,
trials: TRIALS,
budgets: {
pureCall: PURE_CALL_BUDGET,
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
},
loopBaseline: baseline,
advanceSpriteFocus: focus,
applyAircraftBillboardTreatment: aircraft,
productionSpriteTick,
}));
+195
View File
@@ -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"
]
+101
View File
@@ -0,0 +1,101 @@
import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as prettier from 'prettier';
/** Check or format the explicitly adopted files, without scanning other source. */
export async function formatAdoptedFiles(root, mode) {
if (!['--check', '--write'].includes(mode)) {
throw new Error('Usage: node scripts/format.mjs --check|--write');
}
root = await realpath(root);
const scope = JSON.parse(
await readFile(path.join(root, 'scripts/format-scope.json'), 'utf8'),
);
if (
!Array.isArray(scope) ||
!scope.length ||
new Set(scope).size !== scope.length
) {
throw new Error(
'Formatting scope must be a nonempty list of unique file paths',
);
}
// Validate the whole scope before any write, including resolved symlink paths.
const files = [];
for (const name of scope) {
if (
typeof name !== 'string' ||
!name ||
name.includes('\\') ||
path.isAbsolute(name) ||
name.split('/').includes('..')
) {
throw new Error(
'Formatting scope entries must be paths inside the repository',
);
}
const file = path.join(root, name);
const relative = path.relative(root, await realpath(file));
if (
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
!(await stat(file)).isFile()
) {
throw new Error(
`Formatting scope must contain repository files: ${name}`,
);
}
const info = await prettier.getFileInfo(file, {
ignorePath: path.join(root, '.prettierignore'),
});
if (info.ignored || !info.inferredParser) {
throw new Error(
`Formatting scope contains an ignored or unsupported file: ${name}`,
);
}
const options = { ...(await prettier.resolveConfig(file)), filepath: file };
const source = await readFile(file, 'utf8');
files.push({
name,
file,
source,
formatted: await prettier.format(source, options),
});
}
const changed = files.filter(({ source, formatted }) => source !== formatted);
if (mode === '--write') {
for (const { file, formatted } of changed) await writeFile(file, formatted);
}
return { count: files.length, changed: changed.map(({ name }) => name) };
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) {
try {
if (process.argv.length !== 3)
throw new Error('Usage: node scripts/format.mjs --check|--write');
const mode = process.argv[2];
const result = await formatAdoptedFiles(
fileURLToPath(new URL('../', import.meta.url)),
mode,
);
if (mode === '--check' && result.changed.length) {
for (const name of result.changed)
console.error(`Needs formatting: ${name}`);
process.exitCode = 1;
} else {
console.log(
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`,
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+5
View File
@@ -0,0 +1,5 @@
/** Resolve server credentials after applying environment overrides per key. */
export function resolveGoogleServerKey(environment = {}, defaults = {}) {
const value = (name) => String(environment[name] ?? defaults[name] ?? '').trim();
return value('GOOGLE_MAPS_SERVER_API_KEY') || value('GOOGLE_MAPS_API_KEY');
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
if ! command -v security >/dev/null 2>&1; then
echo "error: macOS 'security' command not found"
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "error: node is required to parse JSON credentials"
exit 1
fi
CREDENTIALS_FILE="${1:-${OPENSKY_CREDENTIALS_FILE:-}}"
if [[ -z "${CREDENTIALS_FILE}" ]]; then
echo "usage: ./scripts/opensky-import-client.sh /path/to/credentials.json"
echo "or set OPENSKY_CREDENTIALS_FILE and run ./scripts/opensky-import-client.sh"
exit 1
fi
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
echo "error: credentials file not found: ${CREDENTIALS_FILE}"
exit 1
fi
PARSED="$(node -e "const fs=require('fs');const p=process.argv[1];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();if(!id||!secret){process.exit(2)};process.stdout.write(id+'\t'+secret);" "${CREDENTIALS_FILE}" 2>/dev/null || true)"
if [[ "${PARSED}" != *$'\t'* ]]; then
echo "error: could not parse clientId/clientSecret from ${CREDENTIALS_FILE}"
exit 1
fi
CLIENT_ID="${PARSED%%$'\t'*}"
CLIENT_SECRET="${PARSED#*$'\t'}"
if [[ -z "${CLIENT_ID}" || -z "${CLIENT_SECRET}" ]]; then
echo "error: credentials file is missing client id or client secret"
exit 1
fi
security add-generic-password -U -s "opensky-network" -a "client_id" -w "${CLIENT_ID}" >/dev/null
security add-generic-password -U -s "opensky-network" -a "client_secret" -w "${CLIENT_SECRET}" >/dev/null
echo "OpenSky OAuth client credentials stored in Keychain:"
echo " service=opensky-network account=client_id"
echo " service=opensky-network account=client_secret"
+391
View File
@@ -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": []
}
}
+141
View File
@@ -0,0 +1,141 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { parseEnv } from 'node:util';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
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',
'FIRMS_MAP_KEY',
'TOMTOM_API_KEY',
'OPENSKY_CLIENT_ID',
'OPENSKY_CLIENT_SECRET',
'LL2_API_TOKEN',
'GEV_RATELIMIT_OPENAI_PER_MIN',
'GEV_RATELIMIT_GOOGLE_PER_MIN',
'PINOKIO_SHARE_CLOUDFLARE',
'PINOKIO_SHARE_LOCAL',
'PINOKIO_SHARE_VAR',
]);
const PINOKIO_DEFAULTS = Object.freeze({
GEV_RATELIMIT_OPENAI_PER_MIN: '30',
GEV_RATELIMIT_GOOGLE_PER_MIN: '120',
PINOKIO_SHARE_CLOUDFLARE: 'false',
PINOKIO_SHARE_LOCAL: 'false',
PINOKIO_SHARE_VAR: '__gev_sharing_disabled__',
});
const PINOKIO_SHARE_SENTINEL = '__gev_sharing_disabled__';
const PINOKIO_SHARING_FIELDS = Object.freeze([
'PINOKIO_SHARE_CLOUDFLARE',
'PINOKIO_SHARE_LOCAL',
'PINOKIO_SHARE_VAR',
]);
function appendEnvironmentLine(source, line) {
const prefix = source.length > 0 && !source.endsWith('\n') ? '\n' : '';
return `${source}${prefix}${line}\n`;
}
function detectEnvironmentEncoding(buffer) {
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le';
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be';
let evenNulls = 0;
let oddNulls = 0;
const sampleLength = Math.min(buffer.length, 512);
for (let index = 0; index < sampleLength; index += 1) {
if (buffer[index] !== 0) continue;
if (index % 2 === 0) evenNulls += 1;
else oddNulls += 1;
}
const minimumNulls = Math.max(2, Math.floor(sampleLength / 16));
if (oddNulls >= minimumNulls && oddNulls > evenNulls * 2) return 'utf-16le';
if (evenNulls >= minimumNulls && evenNulls > oddNulls * 2) return 'utf-16be';
return 'utf-8';
}
export function readEnvironmentSource(filepath) {
if (!existsSync(filepath)) return '';
const buffer = readFileSync(filepath);
try {
return new TextDecoder(detectEnvironmentEncoding(buffer), { fatal: true }).decode(buffer);
} catch {
throw new Error('Pinokio ENVIRONMENT could not be decoded as UTF-8 or UTF-16.');
}
}
/** Persist only the non-secret controls Pinokio itself re-reads at local.set. */
export function ensurePinokioSharingBoundary(filepath = DEFAULT_ENVIRONMENT_FILE) {
const original = existsSync(filepath) ? readFileSync(filepath) : null;
let source = readEnvironmentSource(filepath);
try {
if (source) parseEnv(source);
} catch {
throw new Error('Pinokio ENVIRONMENT could not be parsed.');
}
// Pinokio re-reads this file after the child preflight. Remove every legacy,
// blank, or duplicate control before appending one canonical safe block so
// that its later global/app merge cannot diverge from the checked state.
const sharingLine = new RegExp(
`^[\\t ]*(?:${PINOKIO_SHARING_FIELDS.join('|')})[\\t ]*=.*(?:\\r?\\n|$)`,
'gm',
);
source = source.replace(sharingLine, '');
source = appendEnvironmentLine(source, [
'PINOKIO_SHARE_CLOUDFLARE=false',
'PINOKIO_SHARE_LOCAL=false',
`PINOKIO_SHARE_VAR=${PINOKIO_SHARE_SENTINEL}`,
].join('\n'));
let configured;
try {
configured = parseEnv(source);
} catch {
throw new Error('Pinokio ENVIRONMENT could not be parsed.');
}
const encoded = Buffer.from(source, 'utf8');
if (!original || !original.equals(encoded)) {
writeFileSync(filepath, source, { mode: 0o600 });
}
return configured;
}
/** Read the app-scoped Pinokio configuration without exposing its values. */
export function readPinokioEnvironment(filepath = DEFAULT_ENVIRONMENT_FILE) {
if (!existsSync(filepath)) return {};
try {
return parseEnv(readEnvironmentSource(filepath));
} catch {
throw new Error('Pinokio ENVIRONMENT could not be parsed.');
}
}
/**
* Make the app-scoped Pinokio file authoritative over Pinokio-global values.
* Pinokio removes blank entries before merging environments, so each child
* must restore the raw app value before diagnosis or Vite configuration.
*/
export function applyPinokioEnvironment({
environment = process.env,
filepath = DEFAULT_ENVIRONMENT_FILE,
} = {}) {
const configured = ensurePinokioSharingBoundary(filepath);
for (const field of PINOKIO_CONFIG_FIELDS) {
environment[field] = String(configured[field] ?? PINOKIO_DEFAULTS[field] ?? '');
}
// Sharing is unsupported on Pinokio 8.0.40. Never let a global passcode
// enter the child even if the host's global Pinokio environment defines it.
environment.PINOKIO_SHARE_PASSCODE = '';
return configured;
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
import { realpathSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyPinokioEnvironment } from './pinokio-environment.mjs';
import { formatSetupReport, inspectSetup, npmProcessSpec } from './setup-doctor.mjs';
const MODULE_PATH = fileURLToPath(import.meta.url);
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
const READY_FILE = path.join(ROOT, 'pinokio', '.installed');
export function runChecked(command, args, { shell = false } = {}) {
const result = spawnSync(command, args, {
cwd: ROOT,
env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: '1' },
shell,
stdio: 'inherit',
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status || 1);
}
export function installPinokioDependencies() {
applyPinokioEnvironment();
rmSync(READY_FILE, { force: true });
const npm = npmProcessSpec();
runChecked(npm.command, ['ci'], { shell: npm.shell });
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
// Keychain items, so its install report must describe that exact runtime.
const report = inspectSetup({
includeKeychain: false,
// The raw app ENVIRONMENT file was applied above. Even an empty field now
// shadows Vite's dotenv ladder, so diagnosis must stop there instead of
// claiming a dotenv-only value will reach the launched app.
authoritativeEnvironment: true,
});
console.log(`\n${formatSetupReport(report, {
readyMessage: 'Ready. Return to Pinokio and choose Start.',
})}\n`);
if (!report.ready) process.exit(1);
writeFileSync(READY_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
console.log('[Pinokio] Installation ready.');
}
export function isDirectInvocation(
invokedPath = process.argv[1],
modulePath = MODULE_PATH,
) {
if (typeof invokedPath !== 'string' || invokedPath.length === 0) return false;
if (typeof modulePath !== 'string' || modulePath.length === 0) return false;
try {
return realpathSync(path.resolve(invokedPath)) === realpathSync(path.resolve(modulePath));
} catch {
return path.resolve(invokedPath) === path.resolve(modulePath);
}
}
if (isDirectInvocation()) {
installPinokioDependencies();
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export function isPinokioShareEnabled(value) {
return /^(1|true)$/i.test(String(value || '').trim());
}
export function validatePinokioSharing(env = process.env) {
const cloudflare = isPinokioShareEnabled(env.PINOKIO_SHARE_CLOUDFLARE);
const local = isPinokioShareEnabled(env.PINOKIO_SHARE_LOCAL);
const shareVariable = String(env.PINOKIO_SHARE_VAR || '').trim();
if (cloudflare || local || shareVariable !== '__gev_sharing_disabled__') {
throw new Error(
'Pinokio sharing is unavailable because the current supported release can expose the app after child preflight '
+ 'and logs successful tunnel-login passcodes. Keep PINOKIO_SHARE_CLOUDFLARE=false, '
+ 'PINOKIO_SHARE_LOCAL=false, and PINOKIO_SHARE_VAR=__gev_sharing_disabled__.',
);
}
return { cloudflare: false, local: false, protected: false };
}
function run() {
validatePinokioSharing();
console.log('[Pinokio] Local-only launch.');
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
if (invokedPath === fileURLToPath(import.meta.url)) {
try {
run();
} catch (error) {
console.error(`[Pinokio] ${error.message}`);
process.exitCode = 1;
}
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import { rmSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
for (const target of ['node_modules', 'dist', 'pinokio/.installed']) {
rmSync(path.join(ROOT, target), { recursive: true, force: true });
}
console.log('[Pinokio] Installation reset. Local credentials were preserved.');
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
import { realpathSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyPinokioEnvironment } from './pinokio-environment.mjs';
import { isDirectInvocation } from './pinokio-install.mjs';
import { validatePinokioSharing } from './pinokio-preflight.mjs';
const MODULE_PATH = fileURLToPath(import.meta.url);
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
function launchPort(value) {
const port = Number.parseInt(value, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Pinokio did not supply a valid local port.');
}
return port;
}
export async function loadViteFromCanonicalRoot(
root = ROOT,
loadVite = () => import('vite'),
) {
process.chdir(realpathSync(path.resolve(root)));
return loadVite();
}
async function start() {
applyPinokioEnvironment();
validatePinokioSharing();
const port = launchPort(process.env.PORT);
// Provider Settings routes credential writes to pinokio/ENVIRONMENT (never
// .env) when the app runs under this launcher. The marker is set here — after
// applyPinokioEnvironment, before Vite snapshots process.env — so the
// dev-server endpoint knows which store this launch owns.
process.env.GEV_LAUNCHER = 'pinokio';
console.log('[Pinokio] Local-only launch.');
// Import Vite only after app-scoped blank fields have replaced any merged
// Pinokio-global values. Vite snapshots process.env during configuration.
const { createServer } = await loadViteFromCanonicalRoot();
const server = await createServer({
root: ROOT,
server: {
host: '127.0.0.1',
port,
strictPort: true,
},
});
await server.listen();
server.printUrls();
console.log(`[Pinokio] Ready at http://127.0.0.1:${port}/`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
await server.close();
process.exit(0);
});
}
}
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
start().catch((error) => {
console.error(`[Pinokio] Start refused: ${error.message}`);
process.exitCode = 1;
});
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { installPinokioDependencies, runChecked } from './pinokio-install.mjs';
runChecked('git', ['pull', '--ff-only']);
installPinokioDependencies();
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/** Prove standalone startup and terminal resource ownership in a real browser. */
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
try {
const page = await browser.newPage();
const errors = [];
page.on('console', (message) => {
if (
message.type() === 'warn' &&
/^\[Data\].*(?:destroy|disable) error:/.test(message.text())
) {
console.log(message.text());
}
});
page.on('pageerror', (error) => errors.push(error.stack || error.message));
await page.goto(`${url}/?welcome=1`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
timeout: 60_000,
});
const before = await page.evaluate(async () => {
const entry = [...document.scripts].find((script) =>
/\/src\/main\.js(?:\?|$)/.test(script.src),
);
const { application } = await import(entry.src);
window.__qaApplication = application;
const app = window.__godsEyeView;
window.__qaComponents = app;
await application.start();
await app.styleManager.initialRestorePromise;
const enabled = await app.dataManager.setEnabled('local-datacenters', true);
const annotation = await app.annotations.annotate([
{
type: 'pin',
longitude: -97.74,
latitude: 30.27,
label: 'Lifecycle check',
},
]);
return {
status: application.getState().status,
enabled,
layers: app.dataManager.layers.size,
annotationCount: annotation.drawn,
viewerAlive: !app.viewer.isDestroyed(),
credits: Boolean(document.querySelector('#cesium-credits')),
};
});
assert.equal(before.status, 'ready');
assert.equal(before.enabled, true);
assert.ok(before.layers >= 16);
assert.equal(before.annotationCount, 1);
assert.equal(before.viewerAlive, true);
assert.equal(before.credits, true);
console.log(
'PASS: startup, data registration, annotations and visible attribution',
);
const after = await page.evaluate(async () => {
const application = window.__qaApplication;
const app = window.__qaComponents;
const first = application.destroy();
const samePromise = first === application.destroy();
await first.catch((error) => {
const describe = (failure) =>
failure.errors?.map(describe).join('; ') || failure.message;
throw new Error(describe(error));
});
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('resize'));
app.requestRender('after-destroy');
await new Promise((resolve) => setTimeout(resolve, 1400));
return {
status: application.getState().status,
samePromise,
viewerDestroyed: app.viewer.isDestroyed(),
layers: app.dataManager.layers.size,
annotations: app.annotations.count(),
governor: app.getRenderGovernorDiagnostics(),
handlesRemoved:
!window.__godsEyeView &&
!window.__gevVoiceCommands &&
!window.__gevAnnotations,
creditsRemoved: !document.querySelector('#cesium-credits'),
welcomeHidden: !document.querySelector('#first-run-launcher.visible'),
settingsRemoved: !document.querySelector('#key-setup'),
};
});
assert.equal(after.status, 'destroyed');
assert.equal(after.samePromise, true);
assert.equal(after.viewerDestroyed, true);
assert.equal(after.layers, 0);
assert.equal(after.annotations, 0);
assert.equal(after.governor.installed, false);
assert.deepEqual(after.governor.holds, []);
assert.equal(after.handlesRemoved, true);
assert.equal(after.creditsRemoved, true);
assert.equal(after.welcomeHidden, true);
assert.equal(after.settingsRemoved, true);
assert.deepEqual(errors, []);
console.log(
'PASS: terminal teardown releases runtime owners without late browser errors',
);
} finally {
await browser.close();
}
+406
View File
@@ -0,0 +1,406 @@
/**
* qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution).
*
* Findings H10 + H11 (docs/pre-ship-audit-2026-07-01.md):
* H10 — the Google/Cesium credit MUST stay visible in clean-view AND
* recording modes (those are the modes used to record demos).
* H11 — every data layer's required attribution must surface in the
* expandable bottom-left "Data attribution" lightbox.
*
* This script drives the REAL app headless and proves:
* (i) per-layer credits are registered in viewer.creditDisplay (H11),
* and appear in the "Data attribution" lightbox when opened;
* (ii) enabling datacenters + submarine cables keeps their credits present;
* (iii) toggling clean-view keeps #cesium-credits visible (screenshot);
* (iv) toggling recording-mode keeps #cesium-credits visible (screenshot).
*
* Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300
*/
import puppeteer from 'puppeteer';
import { existsSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
function getOpt(flag, def) {
const i = argv.indexOf(flag);
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
}
const APP_URL = getOpt('--url', 'http://localhost:4300');
const APP_ORIGIN = new URL(APP_URL).origin;
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
mkdirSync(SHOT_DIR, { recursive: true });
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
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',
].filter(Boolean);
function findChromeExecutable() {
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => {
try { return existsSync(candidate); } catch { return false; }
}) || null;
}
let passed = 0;
let failed = 0;
function check(name, ok, detail) {
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
if (ok) passed++;
else failed++;
}
// Substrings that MUST be present across the registered per-layer credits.
const REQUIRED_CREDIT_SUBSTRINGS = [
'OpenStreetMap contributors', // ODbL — datacenters/dams/roads
'adsb.lol', // ODbL — military traces
'TeleGeography', // CC BY-NC-SA — cables
'NASA FIRMS', // fires
'CelesTrak', // satellites
'U.S. Geological Survey', // earthquakes
'OpenSky Network', // flights
'AISStream', // vessels
'City of Austin', // CCTV
'Radio Browser', // internet-radio directory
];
async function main() {
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: 'new',
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ summary: 'QA globe ready' }),
});
return;
}
if (url.origin === APP_ORIGIN && url.pathname === '/api/google/nearby-places') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ places: [] }),
});
return;
}
request.continue();
});
const consoleErrors = [];
const failedResponses = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
const sourceUrl = msg.location()?.url || '';
consoleErrors.push(sourceUrl ? `${msg.text()} [${sourceUrl}]` : msg.text());
}
});
page.on('response', (response) => {
if (response.status() >= 500) {
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
}
});
console.log(`\n qa-attribution-b12 → ${APP_URL}\n`);
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Wait for the app + viewer.creditDisplay to be live.
await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
{ timeout: 60000 },
);
// Give Cesium a few frames to render the on-screen credit line (logo + link).
await new Promise((r) => setTimeout(r, 2500));
// ── (i) H11: static credits registered ────────────────────────────
console.log('H11 — per-layer credits registered in viewer.creditDisplay');
const registeredHtml = await page.evaluate(() => {
const cd = window.__godsEyeView.viewer.creditDisplay;
const statics = cd._staticCredits || [];
return statics.map((c) => c.html);
});
check(
'creditDisplay has static per-layer credits',
registeredHtml.length >= 10,
`${registeredHtml.length} static credits registered`,
);
for (const sub of REQUIRED_CREDIT_SUBSTRINGS) {
const found = registeredHtml.some((h) => h.includes(sub));
check(`credit present: "${sub}"`, found, found ? '' : 'MISSING');
}
// ── (i) H11: credits render in the "Data attribution" lightbox ─────
console.log('\nH11 — "Data attribution" lightbox surfaces the credits');
// Force a render frame so the credit display flushes, then open the lightbox.
const lightboxState = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
viewer.scene.requestRender();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const cd = viewer.creditDisplay;
cd.showLightbox();
viewer.scene.requestRender();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const box = document.querySelector('.cesium-credit-lightbox');
const list = box?.querySelector(':scope > ul');
const title = box?.querySelector('.cesium-credit-lightbox-title');
const close = box?.querySelector('.cesium-credit-lightbox-close');
const boxRect = box?.getBoundingClientRect();
const listRect = list?.getBoundingClientRect();
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
const worldOverlay = document.getElementById('world-overlay-root');
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
const stackProbe = document.createElement('div');
stackProbe.dataset.qaAttributionStackProbe = 'true';
stackProbe.textContent = 'QA WORLD LABEL — STACK PROBE';
Object.assign(stackProbe.style, {
position: 'absolute',
left: `${Math.max(8, (boxRect?.left || 0) - 90)}px`,
top: `${Math.max(8, (boxRect?.top || 0) + 150)}px`,
width: '180px',
padding: '5px 8px',
color: '#8ffcff',
background: 'rgba(4, 18, 28, 0.94)',
borderLeft: '3px solid #19d9e8',
font: '11px monospace',
whiteSpace: 'nowrap',
pointerEvents: 'none',
});
worldOverlay?.appendChild(stackProbe);
const stackProbeRect = stackProbe.getBoundingClientRect();
const lastItem = list?.lastElementChild;
if (list) list.scrollTop = list.scrollHeight;
const lastRect = lastItem?.getBoundingClientRect();
return {
html: box?.innerHTML || '',
itemCount: list?.children.length || 0,
linkCount: list?.querySelectorAll('a[href]').length || 0,
everyLinkHasDestination: [...(list?.querySelectorAll('a') || [])]
.every((link) => Boolean(link.getAttribute('href'))),
boxHeight: boxRect?.height || 0,
viewportHeight: window.innerHeight,
listClientHeight: list?.clientHeight || 0,
listScrollHeight: list?.scrollHeight || 0,
listClientWidth: list?.clientWidth || 0,
listScrollWidth: list?.scrollWidth || 0,
listOverflowY: list ? getComputedStyle(list).overflowY : '',
titleVisible: Boolean(title?.getBoundingClientRect().height),
closeVisible: Boolean(close?.getBoundingClientRect().height),
overlayZIndex: Number(getComputedStyle(overlay).zIndex),
worldOverlayZIndex: Number(getComputedStyle(worldOverlay).zIndex),
stackProbeIntersectsModal: Boolean(boxRect
&& stackProbeRect.left < boxRect.left
&& stackProbeRect.right > boxRect.left
&& stackProbeRect.top < boxRect.bottom
&& stackProbeRect.bottom > boxRect.top),
stackProbeExtendsOutsideModal: Boolean(boxRect && stackProbeRect.left < boxRect.left),
lastItemReachable: Boolean(lastRect && listRect
&& lastRect.bottom <= listRect.bottom + 1
&& lastRect.top >= listRect.top - 1),
};
});
const lightboxHtml = lightboxState.html;
const lightboxHits = REQUIRED_CREDIT_SUBSTRINGS.filter((s) => lightboxHtml.includes(s));
check(
'lightbox renders the per-layer credits',
lightboxHits.length >= REQUIRED_CREDIT_SUBSTRINGS.length - 1,
`${lightboxHits.length}/${REQUIRED_CREDIT_SUBSTRINGS.length} required strings visible in lightbox`,
);
check(
'desktop lightbox stays compact with a fixed title and close control',
lightboxState.boxHeight > 0
&& lightboxState.boxHeight <= lightboxState.viewportHeight * 0.71
&& lightboxState.titleVisible
&& lightboxState.closeVisible,
`box=${Math.round(lightboxState.boxHeight)}px viewport=${lightboxState.viewportHeight}px`,
);
check(
'complete credit list scrolls vertically without horizontal overflow',
lightboxState.itemCount >= registeredHtml.length
&& lightboxState.linkCount > 0
&& lightboxState.everyLinkHasDestination
&& lightboxState.listScrollHeight > lightboxState.listClientHeight
&& /auto|scroll/.test(lightboxState.listOverflowY)
&& lightboxState.listScrollWidth <= lightboxState.listClientWidth + 1
&& lightboxState.lastItemReachable,
`${lightboxState.itemCount} items, ${lightboxState.linkCount} links, list=${lightboxState.listClientHeight}/${lightboxState.listScrollHeight}px`,
);
check(
'attribution modal stacks above shared world labels',
Number.isFinite(lightboxState.overlayZIndex)
&& Number.isFinite(lightboxState.worldOverlayZIndex)
&& lightboxState.overlayZIndex > lightboxState.worldOverlayZIndex
&& lightboxState.stackProbeIntersectsModal
&& lightboxState.stackProbeExtendsOutsideModal,
`modal z=${lightboxState.overlayZIndex}, world labels z=${lightboxState.worldOverlayZIndex}, overlap=${lightboxState.stackProbeIntersectsModal}`,
);
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-stacking-before-desktop.png') });
await page.evaluate(() => {
window.__godsEyeView.viewer.creditDisplay.showLightbox();
const list = document.querySelector('.cesium-credit-lightbox > ul');
if (list) list.scrollTop = 0;
});
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-desktop.png') });
await page.evaluate(() => {
window.__godsEyeView.viewer.creditDisplay.hideLightbox();
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
});
await page.setViewport({ width: 560, height: 760, deviceScaleFactor: 1 });
await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
viewer.resize();
viewer.scene.requestRender();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const mobileLightbox = await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
viewer.creditDisplay.showLightbox();
const box = document.querySelector('.cesium-credit-lightbox');
const list = box?.querySelector(':scope > ul');
const rect = box?.getBoundingClientRect();
if (list) list.scrollTop = list.scrollHeight;
const listRect = list?.getBoundingClientRect();
const lastRect = list?.lastElementChild?.getBoundingClientRect();
return {
left: rect?.left || 0,
right: rect?.right || 0,
top: rect?.top || 0,
bottom: rect?.bottom || 0,
viewportWidth: innerWidth,
viewportHeight: innerHeight,
lastItemReachable: Boolean(lastRect && listRect
&& lastRect.bottom <= listRect.bottom + 1),
};
});
check(
'mobile lightbox remains full-screen edge-to-edge and reaches the final credit',
Math.abs(mobileLightbox.left) <= 1
&& Math.abs(mobileLightbox.right - mobileLightbox.viewportWidth) <= 1
&& Math.abs(mobileLightbox.top) <= 1
&& Math.abs(mobileLightbox.bottom - mobileLightbox.viewportHeight) <= 1
&& mobileLightbox.lastItemReachable,
`box=${Math.round(mobileLightbox.left)},${Math.round(mobileLightbox.top)}–${Math.round(mobileLightbox.right)},${Math.round(mobileLightbox.bottom)}`,
);
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-mobile.png') });
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
// ── (ii) enable datacenters + cables; credits still present ────────
console.log('\nH11 — enabling datacenters + submarine cables');
const layerIds = await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
return [...dm.layers.keys()];
});
// Find the datacenter + cable layer ids by fuzzy match on the id string.
const dcId = layerIds.find((id) => /datacenter/i.test(id));
const cableId = layerIds.find((id) => /cable|submarine|telegeo/i.test(id));
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId);
if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId);
await new Promise((r) => setTimeout(r, 800));
const afterEnableHtml = await page.evaluate(() =>
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
);
check(
'datacenter credit (OSM/ODbL) still present after enable',
afterEnableHtml.some((h) => h.includes('OpenStreetMap contributors')),
'',
);
check(
'cable credit (TeleGeography) still present after enable',
afterEnableHtml.some((h) => h.includes('TeleGeography')),
'',
);
// helper: is #cesium-credits visible (line rendered, not display:none)?
const creditVisibility = async () =>
page.evaluate(() => {
const el = document.getElementById('cesium-credits');
if (!el) return { present: false };
const cs = getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
present: true,
display: cs.display,
visibility: cs.visibility,
opacity: cs.opacity,
width: rect.width,
height: rect.height,
text: (el.textContent || '').trim().slice(0, 120),
hasLogo: !!el.querySelector('.cesium-credit-logoContainer, img'),
};
});
// ── (iii) clean-view: credit line STILL visible ───────────────────
console.log('\nH10 — clean-view keeps the credit line visible');
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
await new Promise((r) => setTimeout(r, 400));
const cleanVis = await creditVisibility();
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
check(
'clean-view: #cesium-credits not display:none',
cleanVis.present && cleanVis.display !== 'none' && cleanVis.visibility !== 'hidden',
`display=${cleanVis.display} visibility=${cleanVis.visibility} w=${Math.round(cleanVis.width)} h=${Math.round(cleanVis.height)}`,
);
check(
'clean-view: "Data attribution" link to per-layer popover still present',
!!cleanVis.text && /Data attribution/i.test(cleanVis.text),
`credit text = "${cleanVis.text}"`,
);
await page.evaluate(() => document.body.classList.remove('ui-clean-view'));
// ── (iv) recording-mode: credit line STILL visible ────────────────
console.log('\nH10 — recording-mode keeps the credit line visible');
await page.evaluate(() => document.body.classList.add('recording-mode'));
await new Promise((r) => setTimeout(r, 400));
const recVis = await creditVisibility();
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
check(
'recording-mode: #cesium-credits not display:none',
recVis.present && recVis.display !== 'none' && recVis.visibility !== 'hidden',
`display=${recVis.display} visibility=${recVis.visibility} w=${Math.round(recVis.width)} h=${Math.round(recVis.height)}`,
);
await page.evaluate(() => document.body.classList.remove('recording-mode'));
// baseline (normal) screenshot for comparison
await new Promise((r) => setTimeout(r, 300));
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
check(
'no console errors during QA',
consoleErrors.length === 0,
consoleErrors.slice(0, 3).join(' | ') || 'clean',
);
check(
'no HTTP 5xx responses during QA',
failedResponses.length === 0,
failedResponses.slice(0, 3).join(' | ') || 'clean',
);
await browser.close();
console.log('\n────────────────────────────────────────────────────────────');
console.log(` RESULT: ${passed} passed, ${failed} failed`);
console.log(` Screenshots: ${SHOT_DIR}`);
console.log('────────────────────────────────────────────────────────────\n');
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('qa-attribution-b12 crashed:', e);
process.exit(1);
});
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* qa-cables-overlay — submarine-cable label path measurement + contract gate.
*
* Two jobs:
* 1. MEASURE (both builds, `--legacy` on the pre-migration build): with the
* cables layer ON at a fixed mid-Atlantic camera, capture
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
* b. toggle-ON → first-labels-visible latency,
* c. entity / native-label / host-entry counts,
* d. parked-idle postRender fires over 5 s (render-governor honesty).
* 2. GATE (unified build only): assert the migration contract —
* zero native `LabelGraphics` on cable reference entities, host entries
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
* (no orphan host entries after disable).
*
* Usage: node scripts/qa-cables-overlay.mjs [--url http://localhost:4214] [--legacy]
* Requires a running dev server. Headless; rAF throttling disabled so the
* frame clock is honest (hidden-pane gotcha).
*/
import puppeteer from 'puppeteer';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
const legacy = argv.includes('--legacy');
// --control: never enable the layer; measure the empty-scene orbit cost so the
// cables layer's share of frame time is attributable.
const control = argv.includes('--control');
const 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: 'new',
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();
await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init
// Park mid-Atlantic (many cables + both coasts' landings in range) and
// disable every layer so the cables layer 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: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_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, 6_000)); // tiles + fades settle
// ── b. toggle-ON → labels visible ─────────────────────────────────────
if (!control) {
const labelLatency = await page.evaluate(async (layerId, isLegacy) => {
const gev = window.__godsEyeView;
const t0 = performance.now();
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
const deadline = t0 + 60_000;
const labelsUp = () => {
if (isLegacy) {
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
return (stats.referenceLabelCount || 0) > 0;
}
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
return (diag.paintedBySource?.[layerId] || 0) > 0;
};
while (performance.now() < deadline) {
if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false };
gev.viewer.scene.requestRender?.();
await new Promise((r) => setTimeout(r, 100));
}
return { ms: 60_000, timedOut: true };
}, LAYER_ID, legacy);
report('toggle-ON -> labels visible (ms)', labelLatency);
check('labels became visible after enable', labelLatency.timedOut === false, labelLatency);
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle
}
// ── c. entity / label / host counts ───────────────────────────────────
const counts = await page.evaluate((layerId) => {
const gev = window.__godsEyeView;
const v = gev.viewer;
const out = {
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
referenceEntities: 0, hostEntries: 0, hostPainted: 0,
};
for (let i = 0; i < v.dataSources.length; i++) {
const ds = v.dataSources.get(i);
if (!/TeleGeography/.test(ds.name || '')) continue;
const list = ds.entities.values;
out.dataSources[ds.name] = list.length;
for (const e of list) {
if (e.label !== undefined) out.nativeLabels++;
if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++;
if (e.polyline?.positions && e.polyline.positions.isConstant === false) {
out.dynamicPositionEntities++;
}
}
if (/References/.test(ds.name)) out.referenceEntities = list.length;
}
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
out.referenceLabelCount = stats.referenceLabelCount ?? null;
return out;
}, LAYER_ID);
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
const frameCost = await page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
const scene = v.scene;
const durations = [];
const originalRender = scene.render;
scene.render = function patchedRender(...args) {
const started = performance.now();
const result = originalRender.apply(this, args);
durations.push(performance.now() - started);
return result;
};
const t0 = performance.now();
const tick = () => {
// Slow orbit: every frame is a camera change, so Cesium renders every
// rAF — the honest "user is interacting" cost of the layer.
v.camera.rotateRight(0.0004);
if (performance.now() - t0 < 10_000) requestAnimationFrame(tick);
else {
scene.render = originalRender;
durations.sort((a, b) => a - b);
const n = durations.length;
const sum = durations.reduce((s, d) => s + d, 0);
resolve({
frames: n,
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: +(n / 10).toFixed(1),
});
}
};
requestAnimationFrame(tick);
}));
report(control
? 'scene.render cost, control (cables OFF), 10s orbit'
: 'scene.render cost, cables ON, 10s orbit', frameCost);
// ── d. parked idle honesty (labels must not force continuous render) ──
await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle
const idle = await page.evaluate(() => new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
let renders = 0;
const remove = scene.postRender.addEventListener(() => { renders += 1; });
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
}));
report(control
? 'parked idle, control (postRender fires / 5s)'
: 'parked idle with cables ON (postRender fires / 5s)', idle);
if (!legacy && !control) {
// ── unified-build contract gates ────────────────────────────────────
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
check('host has cable entries', counts.hostEntries > 0, counts.hostEntries);
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
// The cables source never republishes an unchanged cohort (unit pin), and
// since the 2026-08-18 host fix the occluder observer is scoped (body
// childList filtered to inventory chrome; attribute observation
// element-only) and the right-rail allocator writes-if-changed — so a
// parked camera measures 0 postRender fires / 5 s with cables ON, equal
// to the empty-scene control (pre-fix: ~56-61). The headroom below covers
// an occasional genuine chrome transition (loading-chip flip + its 100 ms
// occluder-refresh echo) landing inside the window; ticking churn (~10)
// or the old observer leak (~56+) must fail.
check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle);
// OFF must clear the host source (no orphan labels), ON must restore.
const cycle = await page.evaluate(async (layerId) => {
const gev = window.__godsEyeView;
await gev.dataManager.setEnabled(layerId, false, { origin: 'user' });
gev.viewer.scene.requestRender?.();
await new Promise((r) => setTimeout(r, 1_200));
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
const offEntries = offDiag.entriesBySource?.[layerId] || 0;
const offPainted = offDiag.paintedBySource?.[layerId] || 0;
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
const t0 = performance.now();
let onPainted = 0;
while (performance.now() - t0 < 20_000) {
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
onPainted = diag.paintedBySource?.[layerId] || 0;
if (onPainted > 0) break;
gev.viewer.scene.requestRender?.();
await new Promise((r) => setTimeout(r, 100));
}
return { offEntries, offPainted, onPainted };
}, LAYER_ID);
check('disable clears host entries (no orphans)', cycle.offEntries === 0 && cycle.offPainted === 0, cycle);
check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
}
} finally {
await browser.close();
}
const passed = results.filter((r) => r.pass).length;
console.log(`\nqa-cables-overlay: ${passed}/${results.length} passed${legacy ? ' (legacy measurement mode)' : ''}${control ? ' (control mode)' : ''}`);
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
process.exit(passed === results.length ? 0 : 1);
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/**
* qa-cables-render-probe — replicates the owner-side perf probe methodology
* for the cables layer (relative numbers; SwiftShader when run headless):
*
* 1. PARKED: timed full `viewer.render()` loop (40 calls) with cables ON,
* then OFF, at the fixed mid-Atlantic camera. Reports mean/max.
* 2. PAN-STOP: 3 cycles of { setView to an offset, raise camera.moveEnd,
* wait 650 ms, 20 timed `viewer.render()` calls }. Reports per-cycle
* mean/max and the worst frame across cycles, cables ON vs OFF.
*
* Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216]
*/
import puppeteer from 'puppeteer';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
const LAYER_ID = 'telegeography-submarine-cables';
const browser = await puppeteer.launch({
headless: 'new',
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();
await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
await new Promise((r) => setTimeout(r, 12_000));
await page.evaluate(async () => {
const gev = window.__godsEyeView;
gev.viewer.camera.cancelFlight();
const ell = gev.viewer.scene.globe.ellipsoid;
gev.viewer.camera.setView({
destination: ell.cartographicToCartesian({
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_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 { /* probe */ } }
}
});
await new Promise((r) => setTimeout(r, 5_000));
const stats = (values) => ({
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
max: +Math.max(...values).toFixed(2),
});
const measure = async (label, enabled) => {
await page.evaluate(async (id, on) => {
const gev = window.__godsEyeView;
await gev.dataManager.setEnabled(id, on, { origin: 'user' });
}, LAYER_ID, enabled);
await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle
// 1. PARKED timed renders.
const parked = await page.evaluate(() => {
const v = window.__godsEyeView.viewer;
const durations = [];
for (let i = 0; i < 40; i++) {
const t0 = performance.now();
v.render();
durations.push(performance.now() - t0);
}
return durations;
});
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
const cycles = [];
for (let cycle = 0; cycle < 3; cycle++) {
const frames = await page.evaluate(async (step) => {
const v = window.__godsEyeView.viewer;
const ell = v.scene.globe.ellipsoid;
v.camera.setView({
destination: ell.cartographicToCartesian({
longitude: (-40 + step * 3) * Math.PI / 180,
latitude: 35 * Math.PI / 180,
height: 4_500_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
v.camera.moveEnd.raiseEvent();
await new Promise((resolve) => setTimeout(resolve, 650));
const durations = [];
for (let i = 0; i < 20; i++) {
const t0 = performance.now();
v.render();
durations.push(performance.now() - t0);
}
return durations;
}, cycle + 1);
cycles.push(stats(frames));
}
const worst = Math.max(...cycles.map((c) => c.max));
console.log(` [${label}] parked render: ${JSON.stringify(stats(parked))}`);
console.log(` [${label}] pan-stop cycles: ${JSON.stringify(cycles)} worstFrame=${worst}`);
return { parked: stats(parked), cycles, worst };
};
const on = await measure('cables ON ', true);
const off = await measure('cables OFF', false);
console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`);
console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`);
} finally {
await browser.close();
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* qa-cables-shot — capture the cables layer at two fixed cameras for
* before/after visual-identity comparison. Writes to gitignored qa-shots/.
* Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before]
*/
import puppeteer from 'puppeteer';
import { mkdirSync } from 'node:fs';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot';
const LAYER_ID = 'telegeography-submarine-cables';
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
const browser = await puppeteer.launch({
headless: 'new',
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();
await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
await new Promise((r) => setTimeout(r, 12_000));
await page.evaluate(async (layerId) => {
const gev = window.__godsEyeView;
gev.viewer.camera.cancelFlight();
for (const [id, entry] of gev.dataManager.layers) {
if (entry.enabled && id !== layerId) {
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ }
}
}
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
}, LAYER_ID);
const views = [
{ name: 'atlantic', lon: -40, lat: 35, height: 4_500_000 },
{ name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 },
];
for (const view of views) {
await page.evaluate((v) => {
const viewer = window.__godsEyeView.viewer;
const ell = viewer.scene.globe.ellipsoid;
viewer.camera.setView({
destination: ell.cartographicToCartesian({
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, view);
// Let the sweep + labels settle with frames flowing.
await page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
let ticks = 0;
const tick = () => {
v.scene.requestRender?.();
if (++ticks < 240) requestAnimationFrame(tick); else resolve();
};
requestAnimationFrame(tick);
}));
await new Promise((r) => setTimeout(r, 1_000));
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
await page.screenshot({ path });
console.log(`saved ${path}`);
}
} finally {
await browser.close();
}
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env node
/** Camera-panel browser acceptance with controlled sources, images and terrain. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
const sources = [
{
id: 'qa-camera-a',
name: 'QA Camera A',
lat: 30.2747,
lon: -97.7403,
headingDeg: 30,
},
{
id: 'qa-camera-b',
name: 'QA Camera B',
lat: 30.2672,
lon: -97.7431,
headingDeg: 200,
},
].map((camera) => ({
...camera,
city: 'Austin',
cityId: 'austin',
provider: 'QA camera fixture',
feedType: 'snapshot',
feedConfigured: true,
pitchDeg: -18,
fovDeg: 60,
rangeM: 300,
mountHeightM: 8,
}));
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, ok, detail) => {
console.log(
`[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`,
);
if (!ok) failures++;
};
let delayCameraA = false;
const delayedFrames = new Set();
try {
await page.setViewport({ width: 1440, height: 900 });
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (url.origin !== new URL(origin).origin) return void request.continue();
const json = (body) =>
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
if (url.pathname === '/api/cctv/sources') return void json({ sources });
if (url.pathname === '/api/cctv/health')
return void json({
cameras: sources.map((camera) => ({
id: camera.id,
status: 'ok',
sourceKind: 'snapshot',
label: 'QA camera fixture',
})),
});
if (url.pathname.startsWith('/api/cctv/frame/')) {
const cameraA = url.pathname.endsWith('qa-camera-a');
const respond = () =>
request
.respond({
status: 200,
contentType: 'image/svg+xml',
headers: { 'Cache-Control': 'no-store' },
body: `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${cameraA ? '#16364d' : '#254b32'}"/><path d="M0 260L640 130M240 0L420 360" stroke="#97acb7" stroke-width="26"/><text x="30" y="55" fill="white" font-size="30">QA CAMERA ${cameraA ? 'A' : 'B'}</text></svg>`,
})
.catch(() => {});
if (cameraA && delayCameraA) delayedFrames.add(respond);
else void respond();
return;
}
if (url.pathname === '/api/terrain/heights') {
const points = (url.searchParams.get('points') || '')
.split(';')
.filter(Boolean);
return void json({
results: points.map((point) => {
const [lon, lat] = point.split(',').map(Number);
return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 };
}),
});
}
if (url.pathname === '/api/openai/hud-summary')
return void json({ summary: 'QA camera controls' });
void request.continue();
});
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._cctvControls &&
window.__godsEyeView?.dataManager?.layers?.has('cctv') &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.$eval('[data-collapse-target="cctv-panel"]', (button) =>
button.click(),
);
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.cameras.length === 2 &&
!document.getElementById('cctv-camera-select').disabled,
{ timeout: 30000 },
);
check(
'CCTV enable uses the supplied two-camera catalog',
await page.$eval(
'#cctv-camera-select',
(select) => select.options.length === 2 && !select.disabled,
),
);
await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-a' &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
const state = () =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(),
);
check(
'selected camera, decoded preview and source label agree',
await page.evaluate(() => {
const camera = window.__godsEyeView.dataManager.layers
.get('cctv')
.module.getUIState().activeCamera;
return (
camera.id === 'qa-camera-a' &&
document.getElementById('cctv-frame').src.includes('qa-camera-a') &&
document
.getElementById('cctv-meta')
.textContent.includes('QA camera fixture')
);
}),
);
const coverage = (await state()).coverageMode;
const modes = [];
for (let i = 0; i < 3; i++) {
await page.click('#cctv-coverage-btn');
modes.push((await state()).coverageMode);
}
check(
'coverage cycles through all three modes and returns to its starting state',
new Set(modes).size === 3 && modes.at(-1) === coverage,
modes,
);
const projection = (await state()).showProjection;
await page.click('#cctv-projection-btn');
check(
'projection control updates the layer state',
(await state()).showProjection === !projection,
);
await page.click('#cctv-projection-btn');
await page.click('#cctv-next-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.activeCameraId === 'qa-camera-b',
);
await page.click('#cctv-prev-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.activeCameraId === 'qa-camera-a',
);
check(
'native Next and Previous return to the original camera',
(await state()).activeCameraId === 'qa-camera-a',
);
for (const [selector, key] of [
['#cctv-auto-hop-btn', 'autoHop'],
['#cctv-adjust-btn', 'calibrationMode'],
]) {
const before = (await state())[key];
await page.click(selector);
const changed = (await state())[key] === !before;
await page.click(selector);
check(
`${key} control changes and restores the layer setting`,
changed && (await state())[key] === before,
);
}
// Auto Hop can legitimately advance immediately; restore the calibration target.
await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
.activeCameraId === 'qa-camera-a',
);
const heading = (await state()).activeCamera.headingDeg;
const chip = '.cctv-cal-value[data-cal-field="heading"]';
await page.click(chip);
await page.focus(`${chip} input`);
await page.keyboard.down(selectAllModifier);
await page.keyboard.press('a');
await page.keyboard.up(selectAllModifier);
await page.keyboard.type(String(heading + 10));
await page.keyboard.press('Enter');
check(
'native calibration editing commits an offset for the selected camera',
Math.abs((await state()).activeCamera.headingDeg - (heading + 10)) < 0.01,
);
await page.click('#cctv-calib-save-btn');
check(
'Save persists the selected camera calibration',
await page.evaluate(() =>
Boolean(
JSON.parse(
localStorage.getItem('godsEyeView.cctv.calibration.v2') || '{}',
)['qa-camera-a'],
),
),
);
await page.click('#cctv-calib-reset-btn');
check(
'Reset restores the original pose',
Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01,
);
await page.click(chip);
await page.focus(`${chip} input`);
await page.keyboard.down(selectAllModifier);
await page.keyboard.press('a');
await page.keyboard.up(selectAllModifier);
await page.keyboard.type('110');
await page.keyboard.press('Escape');
check(
'Escape cancels editing without collapsing CCTV',
(await page.evaluate(
() =>
!document.querySelector('.cctv-cal-input') &&
!document.getElementById('cctv-panel').classList.contains('collapsed'),
)) && Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01,
);
await page.click(chip);
await page.evaluate(() => {
const input = document.querySelector('.cctv-cal-input');
input.value = '110';
window.__godsEyeView.dataManager.layers
.get('cctv')
.module.selectCamera('qa-camera-b');
input.dispatchEvent(new Event('blur'));
});
check(
'an external camera selection cancels the prior calibration editor',
(await state()).activeCamera.id === 'qa-camera-b' &&
Math.abs((await state()).activeCamera.headingDeg - 200) < 0.01 &&
(await page.$('.cctv-cal-input')) === null,
);
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-b' &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
await page.setCacheEnabled(false);
delayCameraA = true;
await page.select('#cctv-camera-select', 'qa-camera-a');
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-a' &&
document.getElementById('cctv-frame').dataset.loading === 'true',
);
await page.evaluate(() => {
const image =
window.__godsEyeView.styleManager._cctvControls._cctvFramePreloader;
window.__qaLateCctvImage = image;
window.__qaLateCctvImageLoaded = false;
image.addEventListener(
'load',
() => {
window.__qaLateCctvImageLoaded = true;
},
{ once: true },
);
});
check(
'a newly selected camera clears old pixels while its frame loads',
await page.$eval(
'#cctv-frame-wrap',
(element) => !element.classList.contains('has-frame'),
),
);
await page.select('#cctv-camera-select', 'qa-camera-b');
await page.waitForFunction(
() =>
document.getElementById('cctv-frame').dataset.cameraId ===
'qa-camera-b' &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
delayCameraA = false;
await Promise.all([...delayedFrames].map((respond) => respond()));
delayedFrames.clear();
await page.waitForFunction(() => window.__qaLateCctvImageLoaded);
check(
'late frames cannot overwrite the latest camera selection',
await page.$eval(
'#cctv-frame',
(image) =>
image.dataset.cameraId === 'qa-camera-b' &&
image.src.includes('qa-camera-b'),
),
);
delayCameraA = false;
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
);
const disabledCleanly = await page.$eval(
'#cctv-frame',
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
);
await page.click('#cctv-enable-btn');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.isEnabled('cctv') &&
document
.getElementById('cctv-frame-wrap')
.classList.contains('has-frame'),
);
check(
'disable clears the preview and re-enable reacquires the camera frame',
disabledCleanly,
);
await page.$eval('#cctv-frame', (image) =>
image.scrollIntoView({ block: 'nearest' }),
);
fs.mkdirSync('qa-shots/camera-controls', { recursive: true });
await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' });
await page.evaluate(() => {
const camera = window.__godsEyeView.viewer.camera;
camera.setView({
orientation: {
heading: camera.heading + Math.PI / 3,
pitch: -0.6,
roll: 0,
},
});
});
await new Promise((resolve) => setTimeout(resolve, 700));
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
await page.setViewport({ width: 390, height: 844 });
await new Promise((resolve) => setTimeout(resolve, 700));
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
check(
'narrow camera panel remains within the viewport',
await page.$eval('#cctv-panel', (panel) => {
const rect = panel.getBoundingClientRect();
return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth + 1;
}),
);
check(
'rebuilding Radio preserves the active camera controller and subscription',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
const camera = ui._cctvControls;
const cameraId = camera.getState()?.activeCameraId;
ui._initRadioPanel();
ui._radioControls.connect();
return (
ui._cctvControls === camera &&
!camera.destroyed &&
Boolean(camera._cctvUnsubscribe) &&
camera.getState()?.activeCameraId === cameraId
);
}),
);
await page.evaluate(() => window.__godsEyeView.styleManager.dispose());
check(
'CCTV controller is destroyed with the real UI',
await page.evaluate(
() => window.__godsEyeView.styleManager._cctvControls.destroyed,
),
);
check(
'camera controls produce no uncaught browser errors',
errors.length === 0,
errors,
);
} finally {
await Promise.all([...delayedFrames].map((respond) => respond()));
await browser.close();
}
if (failures) process.exitCode = 1;
File diff suppressed because it is too large. Load diff
Loaded 100 of 663 files, more files were not shown because too many files have changed in this diff. Show more