Merge branch 'main' into fix/gbfs-proxy-redirects-streaming-cap

This commit is contained in:
Devin Lucas authored and GitHub committed 2026-09-09 15:55:11 -06:00
commit db7ff73bbf
25 files changed
+561 -115

No files matched your search

+11
View File
@@ -7,6 +7,17 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Fixed ### Fixed
- 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 - 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 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 the proxy's User-Agent while two of the configured mirrors answer 200 to the
+83 -75
View File
@@ -16,11 +16,15 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
▶️ **From the project behind the viral God's Eye View series** *(formerly WorldView)* — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view) ▶️ **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)
[![#1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](https://github.com/trending) [![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)
🏆 **#1 on GitHub Trending this past week — thank you.** You asked for a one-click install; it's here. 🏆 **Reached #1 on GitHub Trending, daily and weekly · August 2026**
⚡ **No keys, no signup, no config file.** One click through [Pinokio](https://pinokio.computer/) — or `npm install && npm run dev` — and the globe comes to life. Keys are power-ups you paste into the app later. **[→ Quick Start](#-quick-start)** **[#8 Product of the Day](https://www.producthunt.com/products/god-s-eye-view?launch=god-s-eye-view)** · Hunted by Chris Messina, creator of the hashtag
*“pretty cool”* — [Brendan Eich](https://x.com/BrendanEich/status/2094592096401490266), creator of JavaScript and co-founder of Mozilla and Brave · Featured on **[Pinokio](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g)**
⚡ **Start without API keys.** Install with [Pinokio](https://pinokio.co/apps/github-com-bilawalsidhu-gods-eye-view) or run locally from the terminal. Add optional keys inside the app. **[→ Quick Start](#-quick-start)**
</div> </div>
@@ -36,9 +40,9 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
## 🌍 Why This Exists ## 🌍 Why This Exists
**You asked, so it's happening.** God's Eye View is open source. Track the world live. Talk to it. Break it. Extend it. God's Eye View brings public signals into one explorable globe. Track the world live. Talk to it. Break it. Extend it.
Most open-source intelligence is a pile of browser tabs. The signals are abundant, but the *interface* is the bottleneck. God's Eye View turns those signals into a **place**: the world is already broadcasting — flight transponders, ship beacons, orbital elements, seismographs, public cameras — and this makes it visible on a photorealistic 3D Earth in real time. No classified clearance required; it's public signal all the way down, and the interface runs in your browser, under your control. Flight transponders, ship beacons, orbital elements, seismographs, and public cameras already tell us a lot about the world. God's Eye View puts them in the same place, so you can move between a global picture and an individual aircraft, ship, or street. It runs locally in your browser, with source code you can inspect and extend.
> Half the magic is that it looks like a forbidden cockpit. The other half is that every line of code is inspectable. > Half the magic is that it looks like a forbidden cockpit. The other half is that every line of code is inspectable.
@@ -46,9 +50,7 @@ Most feeds are live or regularly refreshed. Traffic is simulated along real
roads using aggregate location data. CCTV camera poses and rocket launch roads using aggregate location data. CCTV camera poses and rocket launch
trajectories are coarse estimates. trajectories are coarse estimates.
You'll be surprised how accessible this is. Free and nearly-free APIs deliver Start with the included data sources, then add your own. Each layer is a separate module.
a surprisingly complete experience out of the box — then it's yours to extend
with bigger data sources whenever you're ready.
--- ---
@@ -79,48 +81,54 @@ with bigger data sources whenever you're ready.
## ⚡ Quick Start ## ⚡ Quick Start
**Nothing to sign up for to get started.** Both paths below land you in the **Start without an account or API keys.** Both paths open the same app with
same place: a live satellite globe — keyless Esri World Imagery with keyless Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
terrain, and OSM stepping in automatically if Esri is ever unreachable — with unreachable. Flights, military traffic, satellites, earthquakes, public
aircraft, military traffic, satellites, earthquakes, public cameras, radio and cameras, radio, and launches are available without keys.
launches already moving on it. No account, no key, no file to edit.
**Optional signups, optimal experience.** The keyless globe gets you running; For photorealistic 3D, add a **Cesium ion token** for eligible personal,
a couple of two-minute signups make it spectacular. Want the photorealistic-3D non-commercial use, or a **Google Maps key** for the direct, metered route and
cities? A **free Cesium ion token** covers them for eligible personal, in-app place search. Provider terms and quotas apply. Add keys through the
non-commercial use — no Google account needed; current ion terms and quotas app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options.
apply. Prefer them straight from Google, plus in-app place search? A
**Google Maps key** is the billing-enabled, metered route — with a surprisingly
generous free tier ([real numbers](#-api-keys)). Either one pastes straight
into **Then power it up** below.
### Path 1 — One click, no terminal ### Path 1 — One click, no terminal
1. Install [Pinokio](https://pinokio.computer/). 1. Install or update [Pinokio](https://desktop.pinokio.co/) to **8.2 or later**.
2. In **Discover → Download from URL**, paste 2. Open [God's Eye View in Pinokio](https://pinokio.co/apps/github-com-bilawalsidhu-gods-eye-view).
`https://github.com/bilawalsidhu/gods-eye-view`.
3. Click **Install**, then **Start**. 3. Click **Install**, then **Start**.
That is the whole thing. The launcher verifies Pinokio's runtime, installs the Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports
cross-platform testing of the fixed installer. The launcher installs the
locked dependencies, finds a free local port, and opens the app. 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 ### Path 2 — Terminal / coding agent
Requires Node.js 24.14.x or 26.x. Node 25 is usable but EOL; the setup doctor Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
warns instead of blocking it. Node 25, which is end-of-life.
```bash ```bash
npm install git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
npm ci
npm run doctor npm run doctor
npm run dev npm run dev
``` ```
Open **`http://localhost:4173`**. Cold start settles in under two seconds on a Open **`http://localhost:4173`**. Choose **Live Contacts**, **Space Missions**,
recent laptop (median 1.86 s in a point-in-time M5/Chrome capture — **Environmental**, or **Explore Manually** from the first-run panel.
[docs/PERFORMANCE.md](docs/PERFORMANCE.md); a comparison baseline, not a hardware
requirement). A first-run card offers to stage a mission for you — **Live <details>
Contacts**, **Space Missions**, **Environmental** — or leaves you to explore <summary>Startup performance</summary>
manually.
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 **macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
configured keys straight from the Keychain. It starts keyless too. configured keys straight from the Keychain. It starts keyless too.
@@ -136,7 +144,8 @@ reopens the same panel.
- **Where keys land:** Pinokio → the app's ignored `pinokio/ENVIRONMENT`; a - **Where keys land:** Pinokio → the app's ignored `pinokio/ENVIRONMENT`; a
terminal clone → the repo-root `.env`. Either file is made owner-only terminal clone → the repo-root `.env`. Either file is made owner-only
*before* a secret is written into it, and it never leaves your machine. *before* a secret is written into it. These are local plaintext files,
excluded from Git; the app uses your keys to contact the providers.
- **Keys you already have stay yours:** values from your shell or the macOS - **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. 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 - **What to get first:** the free [Cesium ion](https://cesium.com/ion) token
@@ -145,12 +154,17 @@ reopens the same panel.
billing-enabled, metered route + place search; OpenAI when you want to talk billing-enabled, metered route + place search; OpenAI when you want to talk
to the world. Full map, costs included, in [Keys & Costs](#-api-keys). to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
> [!WARNING] <details>
> Do not enter credentials in Pinokio 8.0.40's native **Configure** panel: that <summary>Older Pinokio versions and credential storage</summary>
> release does not save this nested app file correctly, and it logs submitted
> values. Use Provider Settings inside the app instead. Both file stores are Do not enter credentials in Pinokio 8.0.40's native **Configure** panel: that
> local plaintext; on macOS the Keychain via `./scripts/dev-fresh.sh` remains release does not save this nested app file correctly, and it logs submitted
> the stronger option. 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 The server binds to **localhost** on both paths, and Provider Settings answers
requests only from your machine. Browser-side keys (Google Maps, Cesium ion) requests only from your machine. Browser-side keys (Google Maps, Cesium ion)
@@ -161,7 +175,7 @@ and it carries the LAN-sharing rules alongside [Keys & Costs](#-api-keys).
## 🕐 The First Five Minutes ## 🕐 The First Five Minutes
No account, no signup. The first-run card will offer to stage a mission for you — or run this gauntlet yourself. Somewhere in these five minutes it stops feeling like a demo: Choose a first-run mission, or try these in order. The GIFs show Google Photorealistic 3D; your starting basemap depends on the keys you've added.
1. **Light up the sky.** Take the **Live Contacts** mission (or turn on **Flights** yourself) — thousands of live aircraft, gliding on real telemetry, detection mesh already reading the scene. Click one: the camera locks on, a trail draws behind it, and its live telemetry card comes up. 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. 2. **Take the controls.** Hit **COCKPIT** on your tracked plane and ride it down, switching sensors mid-flight: NVG into Ironbow FLIR.
@@ -248,7 +262,7 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod
## 🛰️ What's on the Globe ## 🛰️ What's on the Globe
Thirteen live layers. **Eleven of them need nothing at all** — no key, no account, no signup, starting with the satellite basemap you land on. (🟢 nothing · 🟡 free key · 🔴 metered.) Thirteen layers and map sources. **Eleven have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth | | Layer | What you get | Source | Auth |
|-------|--------------|--------|------| |-------|--------------|--------|------|
@@ -258,7 +272,7 @@ Thirteen live layers. **Eleven of them need nothing at all** — no key, no acco
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | | 🚢 **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 | 🟢 | | 🛰️ **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 | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **Traffic** | Live congestion driving per-vehicle flow at street level — dive below ~8 km and the dots color to real jams. Keyless it's an approximate simulation | TomTom + OSM | 🟢 (🟡 TomTom makes it real — get one) | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📹 **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 | 🟢 | | 📻 **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 | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
@@ -322,14 +336,14 @@ Once the basics click, run these:
## 🔧 Under the Hood ## 🔧 Under the Hood
Some of the engineering that makes it feel real rather than like a tech demo: How the globe handles live data:
- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking. - **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. - **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. - **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. - **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating.
- **Spends your quota like it's its own.** The paid feeds run behind cached, budget-governed proxies — an OpenSky credit governor, a TomTom daily tile budget, disk-cached TLEs — so an afternoon of exploring doesn't torch an API allowance. - **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.
- **Secure by design.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider). - **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. - **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
``` ```
@@ -339,9 +353,9 @@ src/
├── hud.js # Intelligence HUD + AI scene summary ├── hud.js # Intelligence HUD + AI scene summary
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only) ├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks ├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
├── iconOrientation.js # Screen-projected world-space headings + horizon cull
├── voice/ # OpenAI Realtime session + 28 voice tools ├── voice/ # OpenAI Realtime session + 28 voice tools
├── data/ # One module per layer + orchestration + context store ├── data/ # One module per layer + orchestration + context store
│ ├── iconOrientation.js # Screen-projected headings + horizon cull
│ └── local_data/ # Bundled datasets (per-folder provenance) │ └── local_data/ # Bundled datasets (per-folder provenance)
└── scenes/ # Cinematic scene director └── scenes/ # Cinematic scene director
``` ```
@@ -352,17 +366,14 @@ See [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md) for the authoritative runti
## 🔑 API Keys ## 🔑 API Keys
**The legend, one more time:** 🟢 **no signup** — works out of the box · 🟡 **free key** — register, paste, done · 🔴 **metered** — a billing-enabled account; costs are small but real. 🟢 **No key** · 🟡 **Free key** · 🔴 **Metered**
Most of the globe is 🟢: flights (anonymous), military traffic, satellites, earthquakes, CCTV, radio, bikeshare, space missions, mapped installations, and every bundled dataset run with **zero keys**. Use **POWER UP → Provider Settings** to add keys. The tables below explain what
each provider enables; none is required to start. See the
[setup instructions](#then-power-it-up--in-the-app-not-in-a-file) for storage
and configuration details.
**And you never have to edit a file to add one.** Click **POWER UP** in the ### Choose the capabilities you want
bottom-right corner of the running app, paste the key into Provider Settings,
hit **SAVE KEYS** — the app writes it to its own local store with owner-only
permissions and restarts itself. Everything below is the map of what each key
actually buys you.
### What you need for the good experience
Six keys. Four have a free tier, and the two 🔴 ones are metered: Six keys. Four have a free tier, and the two 🔴 ones are metered:
@@ -371,9 +382,9 @@ Six keys. Four have a free tier, and the two 🔴 ones are metered:
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | | 🟡 | **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 | | 🔴 | **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 | | 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free, seriously, it's a two-minute signup | | 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | | 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
| 🟡 | **TomTom** | 🚦 Real traffic instead of an approximate simulation | [developer.tomtom.com](https://developer.tomtom.com) — free tier is plenty, completely worth it | | 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif) ![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif)
@@ -386,7 +397,7 @@ Six keys. Four have a free tier, and the two 🔴 ones are metered:
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) | | 🟡 | **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) | | 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
All of them are worth getting. None of them are required to start. Add these if you need higher polling allowances.
`npm run doctor` reports Node/npm readiness, the primary provider routes, and `npm run doctor` reports Node/npm readiness, the primary provider routes, and
where each configured provider was found without printing credential values. where each configured provider was found without printing credential values.
@@ -396,7 +407,10 @@ OpenSky summary reports only OAuth client-pair presence, not the resolved
runtime mode or credential validity; Basic and credentials-file modes remain runtime mode or credential validity; Basic and credentials-file modes remain
advanced `dev-fresh.sh` configuration. advanced `dev-fresh.sh` configuration.
**If you'd rather not use the panel** — headless boxes, coding agents, scripted setups: <details>
<summary>Advanced setup: environment variables and macOS Keychain</summary>
For headless machines, coding agents, or scripted setups:
```bash ```bash
# Put keys in .env (see .env.example), or pass them as env vars: # Put keys in .env (see .env.example), or pass them as env vars:
@@ -412,6 +426,8 @@ security add-generic-password -U -s "cesium-ion" -a "token" -w
OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth credentials with `./scripts/opensky-import-client.sh /path/to/credentials.json`. 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 ### 💸 What it actually costs
Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages: Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages:
@@ -437,22 +453,14 @@ Everything above is the deliberately cheap baseline — enough to get a real tas
### 🔒 Sharing an instance ### 🔒 Sharing an instance
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **set provider-side budget caps** (Google Cloud budgets, OpenAI usage limits): the throttles are app-level guards, not billing caps. Full threat model in [SECURITY.md](SECURITY.md). By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
Provider Settings switches itself off whenever the server is shared. The panel Provider Settings is disabled when the server is shared, so remote users cannot
answers loopback requests only, and any sharing mode disables the surface access the key-entry panel.
outright rather than trusting the socket — tunnelled traffic reaches the server
from loopback too, so socket identity can't carry that boundary. Nobody on your
LAN gets a key-entry form.
Pinokio LAN and Cloudflare sharing are currently unavailable for this launcher. **Pinokio LAN and Cloudflare sharing remain disabled for this launcher.** Use
The supported Pinokio release can activate sharing again when the Open-action a separately reviewed authentication proxy if remote access is required.
URL is registered, and writes a successful tunnel-login passcode into its own [SECURITY.md](SECURITY.md) explains the restrictions and threat model.
notification and terminal stream. Before preflight, the launcher rewrites both
sharing modes to disabled values, clears the child passcode, and pins Pinokio's
share trigger to a disabled sentinel. The app then starts loopback-only and
registers the standard Open URL. Use a separate reviewed authentication proxy
if remote access is required.
--- ---
+19
View File
@@ -2,6 +2,25 @@
Updated: August 24, 2026 Updated: August 24, 2026
## Installations and map-source guidance
- On an uncached Overpass failure, mapped installations keep their existing
30–240 second retry backoff. The top status and Contacts row explain the
outage and scheduled countdown; an active retry says "Retrying mapped
sites" and successful recovery clears the previous error. Known upstream
rate limits, timeouts, and query failures are distinguished without exposing
raw server errors. Failures from other loading layers retain precedence.
- Click a selected installation again or click elsewhere on the map to clear
its selection. Clearing the installation does not clear another layer's
newly selected contact, and refreshes do not revive the cleared site.
- Installation ways and relations without an explicit center use the midpoint
of finite, ordered bounds spanning at most 10 degrees per axis. Explicit
coordinates and centers retain precedence; invalid bounds are dropped.
- Visual-style buttons describe their simulated effects on hover. Unavailable
map-source tooltips and toasts share provider guidance: missing credentials
point to Provider Settings, while a configured Google 3D route that fails
points to restrictions, quota, or connectivity. These hints do not expose keys.
> **2026-08-23 — first-run mission launcher** (`src/firstRunExperience.js`, > **2026-08-23 — first-run mission launcher** (`src/firstRunExperience.js`,
> `#first-run-launcher`, styles at the tail of `style.css`). After startup > `#first-run-launcher`, styles at the tail of `style.css`). After startup
> settles, a fresh session gets one card offering **Live Contacts · Space > settles, a fresh session gets one card offering **Live Contacts · Space
+7 -7
View File
@@ -492,37 +492,37 @@
<div id="control-panel-popover" class="dock-popover-content"> <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> <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"> <div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal"> <button class="style-btn active" data-style="normal" title="Show the globe without a visual filter.">
<span class="btn-icon">◯</span> <span class="btn-icon">◯</span>
<span class="btn-label">Normal</span> <span class="btn-label">Normal</span>
<span class="btn-key">1</span> <span class="btn-key">1</span>
</button> </button>
<button class="style-btn" data-style="retro"> <button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature.">
<span class="btn-icon">▦</span> <span class="btn-icon">▦</span>
<span class="btn-label">CRT</span> <span class="btn-label">CRT</span>
<span class="btn-key">2</span> <span class="btn-key">2</span>
</button> </button>
<button class="style-btn" data-style="surveillance"> <button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette.">
<span class="btn-icon">🌙</span> <span class="btn-icon">🌙</span>
<span class="btn-label">NVG</span> <span class="btn-label">NVG</span>
<span class="btn-key">3</span> <span class="btn-key">3</span>
</button> </button>
<button class="style-btn" data-style="thermal"> <button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color.">
<span class="btn-icon">🌡️</span> <span class="btn-icon">🌡️</span>
<span class="btn-label">FLIR</span> <span class="btn-label">FLIR</span>
<span class="btn-key">4</span> <span class="btn-key">4</span>
</button> </button>
<button class="style-btn" data-style="anime"> <button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines.">
<span class="btn-icon">✦</span> <span class="btn-icon">✦</span>
<span class="btn-label">Anime</span> <span class="btn-label">Anime</span>
<span class="btn-key">5</span> <span class="btn-key">5</span>
</button> </button>
<button class="style-btn" data-style="noir"> <button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading.">
<span class="btn-icon">◐</span> <span class="btn-icon">◐</span>
<span class="btn-label">Noir</span> <span class="btn-label">Noir</span>
<span class="btn-key">6</span> <span class="btn-key">6</span>
</button> </button>
<button class="style-btn" data-style="snow"> <button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene.">
<span class="btn-icon">❄</span> <span class="btn-icon">❄</span>
<span class="btn-label">Snow</span> <span class="btn-label">Snow</span>
<span class="btn-key">7</span> <span class="btn-key">7</span>
+5 -2
View File
@@ -309,7 +309,8 @@ try {
'key-required sources stay focusable, explained, and inert when no ion token is configured', 'key-required sources stay focusable, explained, and inert when no ion token is configured',
ionSource.ariaDisabled === 'true' ionSource.ariaDisabled === 'true'
&& ionSource.focused && ionSource.focused
&& /token required/i.test(ionSource.ariaLabel) // #143 names the missing key: "Needs CESIUM_ION_TOKEN — add it in Provider Settings".
&& /needs [A-Z_]+.*provider settings/i.test(ionSource.ariaLabel)
&& ionSource.activeId === activeBeforeIonAttempt && ionSource.activeId === activeBeforeIonAttempt
&& JSON.stringify(ionSource.active) === JSON.stringify([activeBeforeIonAttempt]), && JSON.stringify(ionSource.active) === JSON.stringify([activeBeforeIonAttempt]),
JSON.stringify(ionSource), JSON.stringify(ionSource),
@@ -736,7 +737,9 @@ try {
check( check(
`a map=${legacyId} link restores to the best available fallback with its tile lit`, `a map=${legacyId} link restores to the best available fallback with its tile lit`,
restored.activeId === expectedLegacyActive restored.activeId === expectedLegacyActive
&& (photorealAvailable ? restored.lastError === null : /unavailable/i.test(restored.lastError || '')) // Keyless photoreal now explains itself as "Needs GOOGLE_MAPS_API_KEY — add it in
// Provider Settings — or a Cesium ion token …"; a keyed-but-failing route still says "unavailable".
&& (photorealAvailable ? restored.lastError === null : /needs [A-Z_]+|unavailable/i.test(restored.lastError || ''))
&& JSON.stringify(restored.pressed) === JSON.stringify([expectedLegacyActive]), && JSON.stringify(restored.pressed) === JSON.stringify([expectedLegacyActive]),
JSON.stringify(restored), JSON.stringify(restored),
); );
+19
View File
@@ -0,0 +1,19 @@
/** Explain mapped-site availability without claiming an unobserved overload. */
export function installationFeedback(stats = {}, now = Date.now()) {
const reasons = {
rate_limited: 'Overpass rate-limited',
timeout: 'Overpass timed out',
query_failed: 'Overpass could not complete the query',
};
const reason = reasons[stats.failureReason] || 'Overpass temporarily unavailable';
if (stats.loading) return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
if (stats.retryAt > 0) {
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
}
if (stats.status === 'unavailable') return reason;
if (stats.status === 'zoom-in') return 'Zoom in to search mapped installations';
if (stats.stale) return 'Showing cached mapped sites';
if (stats.status === 'idle') return 'Mapped sites not loaded';
return 'Mapped sites loaded';
}
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { installationFeedback } from './installationFeedback.js';
test('retry copy follows the real deadline and does not promise an overdue timer fired', () => {
assert.equal(installationFeedback({ retryAt: 31000 }, 1000), 'Overpass temporarily unavailable — retrying in 30s');
assert.match(installationFeedback({ retryAt: 31000 }, 32000), /retry pending$/);
assert.match(installationFeedback({ retryAt: 241000 }, 1000), /240s$/);
});
test('only known failure reasons get specific attribution', () => {
for (const [failureReason, text] of [['rate_limited', 'rate-limited'], ['timeout', 'timed out'], ['query_failed', 'could not complete']]) {
assert.ok(installationFeedback({ status: 'unavailable', failureReason }).includes(text));
}
assert.match(installationFeedback({ status: 'unavailable', failureReason: 'unknown' }), /temporarily unavailable/);
});
test('first fetch, retry, cached data and success have distinct copy', () => {
assert.equal(installationFeedback({ loading: true }), 'Fetching mapped sites…');
assert.equal(installationFeedback({ loading: true, retrying: true }), 'Retrying mapped sites…');
assert.equal(installationFeedback({ status: 'ready' }), 'Mapped sites loaded');
assert.equal(installationFeedback({ stale: true }), 'Showing cached mapped sites');
});
+7
View File
@@ -16,6 +16,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { import {
militaryInstallationCacheKey, militaryInstallationCacheKey,
militaryInstallationFailureReason,
militaryInstallationDiskFresh, militaryInstallationDiskFresh,
militaryInstallationDiskPath, militaryInstallationDiskPath,
migrateMilitaryInstallationEntry, migrateMilitaryInstallationEntry,
@@ -27,6 +28,12 @@ import {
} from '../../vite.config.js'; } from '../../vite.config.js';
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
test('installation failure reasons disclose no raw upstream error and do not guess overload', () => {
assert.equal(militaryInstallationFailureReason(new Error('private network details')), 'unavailable');
assert.equal(militaryInstallationFailureReason({ name: 'AbortError' }), 'timeout');
assert.equal(militaryInstallationFailureReason({ installationReason: 'rate_limited' }), 'rate_limited');
assert.equal(militaryInstallationFailureReason({ installationReason: 'query_failed' }), 'query_failed');
});
const TTL_MS = 30 * DAY_MS; const TTL_MS = 30 * DAY_MS;
/** An entry shaped exactly like what the proxy writes. */ /** An entry shaped exactly like what the proxy writes. */
+5 -3
View File
@@ -292,9 +292,9 @@ function sourceState(layerId) {
// however long the fetch takes. Confirmed live: a held 17 s first fetch // however long the fetch takes. Confirmed live: a held 17 s first fetch
// read `enabling` across 34 samples with the panel non-numeric throughout, // read `enabling` across 34 samples with the panel non-numeric throughout,
// and a failing one settled to `enabled` with status 'unavailable'. Its // and a failing one settled to `enabled` with status 'unavailable'. Its
// getStats() has no `loading` status to offer in any case — // getStats() also reports loading while a request is in progress —
// setInstallationStatus is only ever called with // setInstallationStatus is only ever called with
// zoom-in/ready/stale/empty/unavailable. // loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and // - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers, // update() both resolve as soon as the first /api/ais-live poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket // so the lifecycle settles to `enabled` — but until the server-side socket
@@ -348,7 +348,9 @@ function isSame(subject, item, prefix, key) {
*/ */
export function summarizeInstallationViewport(items, source) { export function summarizeInstallationViewport(items, source) {
const summary = summarizeAwarenessCohortForNavigation(items, source); const summary = summarizeAwarenessCohortForNavigation(items, source);
if (summary.count === null) return summary; if (summary.count === null) return source.stats?.statusMessage
? { ...summary, reason: source.stats.statusMessage }
: summary;
return { return {
...summary, ...summary,
reason: summary.count reason: summary.count
+5
View File
@@ -1530,6 +1530,11 @@ test('installation summaries disclose viewport-scoped coverage', () => {
const unavailable = summarizeInstallationViewport([], { available: false, stale: false }); const unavailable = summarizeInstallationViewport([], { available: false, stale: false });
assert.equal(unavailable.reason, 'feed unavailable'); assert.equal(unavailable.reason, 'feed unavailable');
const retrying = summarizeInstallationViewport([], { available: false, stats: {
statusMessage: 'Overpass temporarily unavailable — retrying in 30s',
} });
assert.equal(retrying.count, null, 'retrying is not a claim of zero mapped sites');
assert.equal(retrying.reason, 'Overpass temporarily unavailable — retrying in 30s');
}); });
test('compact Context snapshots retain installation coverage', () => { test('compact Context snapshots retain installation coverage', () => {
+32 -1
View File
@@ -54,7 +54,38 @@ function finiteLongitude(value) {
function pointFrom(element) { function pointFrom(element) {
const lat = Number(element?.lat ?? element?.center?.lat); const lat = Number(element?.lat ?? element?.center?.lat);
const longitude = Number(element?.lon ?? element?.center?.lon); const longitude = Number(element?.lon ?? element?.center?.lon);
return finiteLatitude(lat) && finiteLongitude(longitude) ? { latitude: lat, longitude } : null; if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
// geometry mode only — `geom` wins and no `center` is ever emitted. Every
// way and relation therefore arrived point-less and was dropped here, so the
// layer rendered nodes and nothing else (San Diego: 149 of 228 kept, all the
// ways and relations gone; Warendorf has no nodes at all, hence an empty
// screen over a mapped Bundeswehr barracks). `bounds` accompanies exactly
// those elements — including relations, which carry no `geometry` — so it
// recovers all of them without touching the query or the footprint path.
const bounds = element?.bounds;
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
const south = Number(bounds?.minlat);
const west = Number(bounds?.minlon);
const north = Number(bounds?.maxlat);
const east = Number(bounds?.maxlon);
// Ordering matters as much as range: an inverted or antimeridian-spanning
// box midpoints to a plausible-looking point in the wrong ocean, so match
// isValidInstallationBoundingBox and refuse it rather than average it.
// A single installation footprint is never wider than the request bbox cap
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
// 179) passes the ordering check yet midpoints to longitude 0.
if (finiteLatitude(south) && finiteLatitude(north)
&& finiteLongitude(west) && finiteLongitude(east)
&& south <= north && west <= east
&& north - south <= 10 && east - west <= 10) {
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
}
return null;
} }
function footprintFrom(element) { function footprintFrom(element) {
@@ -74,3 +74,79 @@ test('accepts only small non-dateline request bboxes', () => {
assert.equal(isValidInstallationBoundingBox({ south: -1, west: 179, north: 1, east: -179 }), false); assert.equal(isValidInstallationBoundingBox({ south: -1, west: 179, north: 1, east: -179 }), false);
assert.equal(isValidInstallationBoundingBox({ south: -20, west: 0, north: 20, east: 1 }), false); assert.equal(isValidInstallationBoundingBox({ south: -20, west: 0, north: 20, east: 1 }), false);
}); });
test('a way with only bounds is kept, at the midpoint of that box', () => {
// The proxy asks for `out center tags geom` and Overpass honours only the
// LAST geometry mode, so `center` is never emitted and every way/relation
// arrives carrying `bounds` instead. Dropping those rendered nodes and
// nothing else — an empty screen over mapped installations.
const result = normalizeMilitaryInstallations({ elements: [
{ type: 'way', id: 92701457, bounds: { minlat: 51.94, minlon: 7.98, maxlat: 51.96, maxlon: 8.02 },
tags: { military: 'barracks', name: 'Sportschule der Bundeswehr' } },
] }, '2026-08-28T00:00:00.000Z');
assert.equal(result.records.length, 1);
assert.equal(result.droppedCount, 0);
assert.equal(result.records[0].latitude, 51.95);
assert.equal(result.records[0].longitude, 8.00);
});
test('a relation with only bounds is kept — it carries no geometry at all', () => {
const result = normalizeMilitaryInstallations({ elements: [
{ type: 'relation', id: 5, bounds: { minlat: -1, minlon: -2, maxlat: 1, maxlon: 2 },
tags: { landuse: 'military' } },
] });
assert.equal(result.records.length, 1);
assert.equal(result.records[0].latitude, 0);
assert.equal(result.records[0].longitude, 0);
});
test('an explicit centre still wins over bounds', () => {
const result = normalizeMilitaryInstallations({ elements: [
{ type: 'way', id: 6, center: { lat: 30.2, lon: -97.7 },
bounds: { minlat: 0, minlon: 0, maxlat: 60, maxlon: 60 },
tags: { military: 'airfield' } },
] });
assert.equal(result.records[0].latitude, 30.2);
assert.equal(result.records[0].longitude, -97.7);
});
test('an out-of-range or incomplete bounds box is dropped, not averaged', () => {
const result = normalizeMilitaryInstallations({ elements: [
// latitude past the pole
{ type: 'way', id: 1, bounds: { minlat: 80, minlon: 0, maxlat: 95, maxlon: 1 }, tags: { military: 'range' } },
// longitude past the antimeridian
{ type: 'way', id: 2, bounds: { minlat: 0, minlon: 170, maxlat: 1, maxlon: 181 }, tags: { military: 'range' } },
// a half-filled box would average to a plausible-looking lie
{ type: 'way', id: 3, bounds: { minlat: 10, maxlat: 12 }, tags: { military: 'range' } },
{ type: 'way', id: 4, bounds: {}, tags: { military: 'range' } },
{ type: 'way', id: 5, tags: { military: 'range' } },
] });
assert.deepEqual(result.records, []);
assert.equal(result.droppedCount, 5);
});
test('non-numeric bounds never coerce into a mapped site at zero', () => {
for (const value of [null, false, '', '0']) {
const result = normalizeMilitaryInstallations({ elements: [{
type: 'way', id: 90, tags: { military: 'barracks' },
bounds: { minlat: value, minlon: value, maxlat: value, maxlon: value },
}] });
assert.equal(result.records.length, 0);
assert.equal(result.droppedCount, 1);
}
});
test('an inverted or dateline-spanning bounds box is dropped rather than midpointed', () => {
const result = normalizeMilitaryInstallations({ elements: [
// south above north
{ type: 'way', id: 6, bounds: { minlat: 10, minlon: 0, maxlat: 8, maxlon: 1 }, tags: { military: 'range' } },
// west east of east: a dateline-spanning box would midpoint to longitude 0
{ type: 'way', id: 7, bounds: { minlat: 5, minlon: 179, maxlat: 6, maxlon: -179 }, tags: { military: 'range' } },
// ascending but implausibly wide: Overpass reports an antimeridian feature
// as minlon -179 / maxlon 179, which also midpoints to longitude 0
{ type: 'way', id: 8, bounds: { minlat: 5, minlon: -179, maxlat: 6, maxlon: 179 }, tags: { military: 'range' } },
] });
assert.deepEqual(result.records, []);
assert.equal(result.droppedCount, 3);
});
+39 -4
View File
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js'; import { governorRequestRender } from '../renderGovernor.js';
import { import {
clearSelectedEntityContextForLayer, clearSelectedEntityContextForLayer,
getSelectedEntityContext,
registerEntityContext, registerEntityContext,
removeEntityContextsForLayer, removeEntityContextsForLayer,
selectEntityContext, selectEntityContext,
@@ -17,6 +18,7 @@ import {
// sequentially so overlapping renders cannot stack requests on the proxy. // sequentially so overlapping renders cannot stack requests on the proxy.
import { warmFireAnchorFloors } from './fireAnchors.js'; import { warmFireAnchorFloors } from './fireAnchors.js';
import { normalizeMilitaryInstallations } from './militaryInstallationData.js'; import { normalizeMilitaryInstallations } from './militaryInstallationData.js';
import { installationFeedback } from './installationFeedback.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
const LAYER_ID = 'military-installations'; const LAYER_ID = 'military-installations';
@@ -74,6 +76,8 @@ const state = {
retryTimer: null, retryTimer: null,
/** Current backoff step for that retry; 0 = next failure starts at the minimum. */ /** Current backoff step for that retry; 0 = next failure starts at the minimum. */
retryDelayMs: 0, retryDelayMs: 0,
retryAt: 0,
failureReason: null,
moveEndRemove: null, moveEndRemove: null,
clickHandler: null, clickHandler: null,
timer: null, timer: null,
@@ -239,7 +243,13 @@ function renderableRecords() {
return selected ? [...rendered, selected] : rendered; return selected ? [...rendered, selected] : rendered;
} }
function renderRecords() { function renderRecords({ claimSelection = false } = {}) {
// Context navigation can select another layer without a canvas click.
// A delayed floor/data repaint must not steal that newer selection back.
const selectedContext = getSelectedEntityContext();
if (!claimSelection && state.selectedId && selectedContext && selectedContext.id !== state.selectedId) {
state.selectedId = null;
}
// Post-moveEnd debounced fetches commit after the camera settles; the // Post-moveEnd debounced fetches commit after the camera settles; the
// rebuilt entities need one frame in idle mode. (perf wave 2 fix) // rebuilt entities need one frame in idle mode. (perf wave 2 fix)
governorRequestRender('installations-render'); governorRequestRender('installations-render');
@@ -339,7 +349,7 @@ function selectRecord(id) {
const record = state.recordById.get(id); const record = state.recordById.get(id);
if (!record || !state.dataSource) return false; if (!record || !state.dataSource) return false;
state.selectedId = id; state.selectedId = id;
renderRecords(); renderRecords({ claimSelection: true });
// renderRecords drops selectedId when the record produced no entity. // renderRecords drops selectedId when the record produced no entity.
return state.selectedId === id; return state.selectedId === id;
} }
@@ -351,7 +361,16 @@ function installInteraction(viewer) {
if (!state.enabled) return; if (!state.enabled) return;
const picked = viewer.scene.pick(click.position); const picked = viewer.scene.pick(click.position);
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
if (id && state.recordById.has(id)) selectRecord(id); if (id && state.recordById.has(id) && id !== state.selectedId) {
selectRecord(id);
} else if (state.selectedId) {
// Clicking the selected site again, empty map, or another contact
// releases this layer's selection. Clear only our shared context so a
// sibling click handler's newly selected aircraft/site stays intact.
state.selectedId = null;
clearSelectedEntityContextForLayer(LAYER_ID);
renderRecords();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
} }
@@ -379,8 +398,10 @@ function scheduleUnavailableRetry() {
if (!state.enabled) return; if (!state.enabled) return;
clearTimeout(state.retryTimer); clearTimeout(state.retryTimer);
state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs); state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs);
state.retryAt = Date.now() + state.retryDelayMs;
state.retryTimer = setTimeout(() => { state.retryTimer = setTimeout(() => {
state.retryTimer = null; state.retryTimer = null;
state.retryAt = 0;
if (state.enabled && !state.loading) loadInstallations(); if (state.enabled && !state.loading) loadInstallations();
}, state.retryDelayMs); }, state.retryDelayMs);
} }
@@ -388,6 +409,7 @@ function scheduleUnavailableRetry() {
function clearUnavailableRetry({ resetBackoff = true } = {}) { function clearUnavailableRetry({ resetBackoff = true } = {}) {
clearTimeout(state.retryTimer); clearTimeout(state.retryTimer);
state.retryTimer = null; state.retryTimer = null;
state.retryAt = 0;
if (resetBackoff) state.retryDelayMs = 0; if (resetBackoff) state.retryDelayMs = 0;
} }
@@ -415,13 +437,18 @@ async function loadInstallations() {
const requestAbort = new AbortController(); const requestAbort = new AbortController();
state.abort = requestAbort; state.abort = requestAbort;
state.loading = true; state.loading = true;
clearUnavailableRetry({ resetBackoff: false });
// The previous attempt's failure is not the outcome of this new attempt.
setInstallationStatus('loading');
try { try {
const fetchInstallations = async (exact) => { const fetchInstallations = async (exact) => {
const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)])); const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)]));
if (exact) query.set('exact', '1'); if (exact) query.set('exact', '1');
const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal }); const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal });
const body = await response.json(); const body = await response.json();
if (!response.ok) throw new Error(body?.error || `Installation feed HTTP ${response.status}`); if (!response.ok) throw Object.assign(new Error(body?.error || `Installation feed HTTP ${response.status}`), {
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(body?.reason) ? body.reason : 'unavailable',
});
return body; return body;
}; };
@@ -491,6 +518,7 @@ async function loadInstallations() {
// Even the exact-viewport retry can saturate in a dense area. Say so rather // Even the exact-viewport retry can saturate in a dense area. Say so rather
// than implying the view is completely surveyed. // than implying the view is completely surveyed.
state.saturated = saturated; state.saturated = saturated;
state.failureReason = null;
clearUnavailableRetry(); clearUnavailableRetry();
setInstallationStatus( setInstallationStatus(
state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty', state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty',
@@ -502,6 +530,7 @@ async function loadInstallations() {
warmInstallationFloors(state.records); warmInstallationFloors(state.records);
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') return; if (error?.name === 'AbortError') return;
state.failureReason = error?.failureReason || 'unavailable';
setInstallationStatus('unavailable', error?.message || 'Installation context unavailable'); setInstallationStatus('unavailable', error?.message || 'Installation context unavailable');
scheduleUnavailableRetry(); scheduleUnavailableRetry();
} finally { } finally {
@@ -545,6 +574,8 @@ const militaryInstallationsLayer = {
if (state.dataSource) state.dataSource.show = false; if (state.dataSource) state.dataSource.show = false;
clearSelectedEntityContextForLayer(LAYER_ID); clearSelectedEntityContextForLayer(LAYER_ID);
state.selectedId = null; state.selectedId = null;
state.failureReason = null;
setInstallationStatus('idle');
}, },
update() { return loadInstallations(); }, update() { return loadInstallations(); },
/** Request a one-shot Google Maps Places search around the current map view. */ /** Request a one-shot Google Maps Places search around the current map view. */
@@ -632,6 +663,10 @@ const militaryInstallationsLayer = {
error: state.error, error: state.error,
status: state.status, status: state.status,
loading: state.loading, loading: state.loading,
retryAt: state.retryAt,
retrying: state.loading && Boolean(state.failureReason),
failureReason: state.failureReason,
statusMessage: installationFeedback({ ...state, retrying: state.loading && Boolean(state.failureReason) }),
loadingLabel: state.loading ? 'loading mapped installation context' : '', loadingLabel: state.loading ? 'loading mapped installation context' : '',
}; };
}, },
+71 -2
View File
@@ -23,6 +23,62 @@ import {
installRenderGovernor, installRenderGovernor,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { registerEntityContext, selectEntityContext, getSelectedEntityContext } from './contextStore.js';
test('clicking a selected installation again or empty map clears it through refresh', async () => {
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
try {
for (const nextPick of ['osm:node:42', null]) {
run.click('osm:node:42');
assert.equal(getSelectedEntityContext()?.id, 'osm:node:42');
run.click(nextPick);
assert.equal(getSelectedEntityContext(), null);
await militaryInstallationsLayer.update();
assert.equal(getSelectedEntityContext(), null, 'refresh must not resurrect selection');
assert.equal(run.entities()[0].point.pixelSize.getValue(), 9);
}
} finally { run.restore(); }
});
test('clearing a stale installation highlight does not clear or reclaim another layer selection', async () => {
const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42,
lat: 30.2, lon: -97.7, tags: { military: 'base', name: 'Site' } }] });
try {
run.click('osm:node:42');
const aircraft = { id: 'aircraft:test' };
registerEntityContext(aircraft, { id: aircraft.id, layerId: 'military', label: 'Aircraft' });
selectEntityContext(aircraft);
await militaryInstallationsLayer.update();
assert.equal(getSelectedEntityContext()?.id, aircraft.id, 'non-canvas selection survives a repaint');
run.click('osm:node:42');
selectEntityContext(aircraft);
run.click(aircraft);
assert.equal(getSelectedEntityContext()?.id, aircraft.id);
await militaryInstallationsLayer.update();
assert.equal(getSelectedEntityContext()?.id, aircraft.id);
} finally { run.restore(); }
});
test('switching sites keeps the new selection through refresh and disable clears it', async () => {
const run = await runInstallationLoad({ elements: [42, 43].map(id => ({ type: 'node', id,
lat: 30.2, lon: -97.7, tags: { military: 'base', name: `Site ${id}` } })) });
try {
run.click('osm:node:42');
run.click('osm:node:43');
assert.equal(getSelectedEntityContext()?.id, 'osm:node:43');
await militaryInstallationsLayer.update();
assert.equal(getSelectedEntityContext()?.id, 'osm:node:43');
assert.equal(run.entities().find(e => e.id === 'osm:node:42').point.pixelSize.getValue(), 9);
assert.equal(run.entities().find(e => e.id === 'osm:node:43').point.pixelSize.getValue(), 13);
militaryInstallationsLayer.disable();
run.click('osm:node:42');
assert.equal(getSelectedEntityContext(), null, 'disabled layer ignores clicks');
militaryInstallationsLayer.enable();
await militaryInstallationsLayer.update();
assert.equal(getSelectedEntityContext(), null);
} finally { run.restore(); }
});
test('cheap installation distance prefilter is local and antimeridian-safe', () => { test('cheap installation distance prefilter is local and antimeridian-safe', () => {
const oneDegree = approximateSurfaceDistanceM(0, 0, 0, 1); const oneDegree = approximateSurfaceDistanceM(0, 0, 0, 1);
@@ -233,6 +289,8 @@ async function runInstallationLoad({
}; };
const dataSources = []; const dataSources = [];
const cameraFlights = []; const cameraFlights = [];
let picked = null;
let clickAction;
const viewer = { const viewer = {
camera: { camera: {
moveEnd: { addEventListener() { return () => {}; } }, moveEnd: { addEventListener() { return () => {}; } },
@@ -249,7 +307,7 @@ async function runInstallationLoad({
scene: { scene: {
canvas: { addEventListener() {}, removeEventListener() {} }, canvas: { addEventListener() {}, removeEventListener() {} },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
pick() { return null; }, pick() { return picked; },
// Enough surface for the real render governor to drive this viewer, so // Enough surface for the real render governor to drive this viewer, so
// one-shot render requests are observable. // one-shot render requests are observable.
requestRenderMode: false, requestRenderMode: false,
@@ -266,7 +324,13 @@ async function runInstallationLoad({
}, },
}; };
militaryInstallationsLayer.init(viewer); const originalSetInputAction = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (action, type, modifier) {
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) clickAction = action;
return originalSetInputAction.call(this, action, type, modifier);
};
try { militaryInstallationsLayer.init(viewer); }
finally { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = originalSetInputAction; }
installRenderGovernor(viewer); installRenderGovernor(viewer);
militaryInstallationsLayer.enable(); militaryInstallationsLayer.enable();
await militaryInstallationsLayer.update(); await militaryInstallationsLayer.update();
@@ -277,6 +341,11 @@ async function runInstallationLoad({
entities: () => dataSources[0]?.entities?.values || [], entities: () => dataSources[0]?.entities?.values || [],
contextLabels: () => contextEvents, contextLabels: () => contextEvents,
stats: () => militaryInstallationsLayer.getStats(), stats: () => militaryInstallationsLayer.getStats(),
click(target) {
const entity = typeof target === 'string' ? dataSources[0].entities.getById(target) : target;
picked = entity ? { id: entity } : undefined;
clickAction({ position: { x: 0, y: 0 } });
},
renderRequests: () => getRenderGovernorDiagnostics().recentRequests.map((item) => item.reason), renderRequests: () => getRenderGovernorDiagnostics().recentRequests.map((item) => item.reason),
restore() { restore() {
militaryInstallationsLayer.destroy(viewer); militaryInstallationsLayer.destroy(viewer);
+7
View File
@@ -263,6 +263,13 @@ export function knownKeySetupEnvVars() {
return names; return names;
} }
/** Tooltip guidance for a control gated by one registry entry. */
export function keySetupRequirement(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
if (!entry) return '';
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
}
/** /**
* Decide whether a live provider value belongs to a source outside the store * Decide whether a live provider value belongs to a source outside the store
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source * Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
+9
View File
@@ -8,12 +8,21 @@ import {
commandCompletedSuccessfully, commandCompletedSuccessfully,
isKeySetupExternallyManaged, isKeySetupExternallyManaged,
keySetupStatus, keySetupStatus,
keySetupRequirement,
knownKeySetupEnvVars, knownKeySetupEnvVars,
parseWindowsUserSid, parseWindowsUserSid,
upsertDotenvValues, upsertDotenvValues,
validateKeySetupUpdates, validateKeySetupUpdates,
} from './keySetupCore.mjs'; } from './keySetupCore.mjs';
test('provider requirements name the registry env vars and next step', () => {
assert.equal(
keySetupRequirement('cesium-ion'),
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.equal(keySetupRequirement('unknown'), '');
});
test('the boot provenance snapshot survives in-process Vite config re-evaluation', () => { test('the boot provenance snapshot survives in-process Vite config re-evaluation', () => {
// server.restart() re-evaluates vite.config.js in the SAME process after a // server.restart() re-evaluates vite.config.js in the SAME process after a
// panel save has already set its values live on process.env. A recomputed // panel save has already set its values live on process.env. A recomputed
+37 -5
View File
@@ -1,3 +1,5 @@
import { installationFeedback } from './data/installationFeedback.js';
export const LOADING_REVEAL_DELAY_MS = 160; export const LOADING_REVEAL_DELAY_MS = 160;
export const LOADING_TERMINAL_DWELL_MS = 2200; export const LOADING_TERMINAL_DWELL_MS = 2200;
export const LOADING_FAILURE_DWELL_MS = 5000; export const LOADING_FAILURE_DWELL_MS = 5000;
@@ -17,10 +19,11 @@ export function normalizeLayerLoading(layer = {}) {
const disabling = lifecycleState === 'disabling'; const disabling = lifecycleState === 'disabling';
const loading = lifecycleState === 'enabling' || disabling || stats.loading === true || stats.refreshing === true; const loading = lifecycleState === 'enabling' || disabling || stats.loading === true || stats.refreshing === true;
const count = finiteCount(stats.count); const count = finiteCount(stats.count);
const error = stats.error || stats.lastError || stats.managerRefreshError || null; const stoppingInstallations = layer.id === 'military-installations' && disabling;
const unavailable = stats.unavailable === true const error = stoppingInstallations ? null : stats.error || stats.lastError || stats.managerRefreshError || null;
const unavailable = !stoppingInstallations && (stats.unavailable === true
|| stats.available === false || stats.available === false
|| ['unavailable', 'offline', 'down', 'error'].includes(status); || ['unavailable', 'offline', 'down', 'error'].includes(status));
const keyRequired = stats.keyRequired === true || stats.missingKey === true; const keyRequired = stats.keyRequired === true || stats.missingKey === true;
const degraded = stats.degraded === true || Boolean(error); const degraded = stats.degraded === true || Boolean(error);
const accepted = Boolean(stats.lastUpdate) || count > 0; const accepted = Boolean(stats.lastUpdate) || count > 0;
@@ -37,6 +40,10 @@ export function normalizeLayerLoading(layer = {}) {
unavailable, unavailable,
keyRequired, keyRequired,
degraded, degraded,
installationRetry: layer.id === 'military-installations' && layer.enabled && !disabling
? { retryAt: Number(stats.retryAt) || 0, retrying: stats.retrying === true,
failureReason: stats.failureReason, loading, status: stats.status }
: null,
}; };
} }
@@ -129,7 +136,7 @@ export function canPresentDeferredStatusNotice(expectedGeneration, currentGenera
*/ */
export function presentGlobalLoadingStatus(notice, loadingState, summary, nowMs = 0) { export function presentGlobalLoadingStatus(notice, loadingState, summary, nowMs = 0) {
const loadingPresentation = presentLoadingFeedback(loadingState, summary, nowMs); const loadingPresentation = presentLoadingFeedback(loadingState, summary, nowMs);
if (loadingPresentation?.state === 'error') return loadingPresentation; if (['error', 'retry'].includes(loadingPresentation?.state)) return loadingPresentation;
return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation; return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation;
} }
@@ -244,6 +251,10 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
batchOutcome, batchOutcome,
terminal: null, terminal: null,
operation: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading', operation: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading',
failedEventIds: [...new Set([
...(beginning ? [] : state.failedEventIds || []),
...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []),
])],
}; };
} }
@@ -267,6 +278,10 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
hideAt: now + dwell, hideAt: now + dwell,
batchOutcome: terminal, batchOutcome: terminal,
terminal, terminal,
failedEventIds: [...new Set([
...(state.failedEventIds || []),
...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []),
])],
}; };
} }
@@ -276,15 +291,32 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
/** Build the user-facing status copy for the current loading state. */ /** Build the user-facing status copy for the current loading state. */
export function presentLoadingFeedback(state, summary, nowMs) { export function presentLoadingFeedback(state, summary, nowMs) {
const site = summary.records.find(record => record.installationRetry?.retryAt > 0);
const otherFailure = summary.records.some(record => record.id !== 'military-installations'
&& (state?.activeIds || []).includes(record.id)
&& (record.error || record.unavailable || record.keyRequired))
|| (state?.failedEventIds || []).some(id => id !== 'military-installations');
// Keep the actual retry visible between attempts, without hiding another
// participant's failure or pretending that a scheduled retry is fetching.
if (site && !summary.active.length && !otherFailure) {
const message = installationFeedback(site.installationRetry);
const [label, detail] = message.split(' — ');
return { state: 'retry', label: label.toUpperCase(), detail: detail || '' };
}
if (!state?.visible) return null; if (!state?.visible) return null;
if (state.phase === 'terminal') { if (state.phase === 'terminal') {
const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', error: 'LOAD FAILED' }; const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', error: 'LOAD FAILED' };
const label = state.operation === 'disabling' && state.terminal === 'complete' const label = state.operation === 'disabling' && state.terminal === 'complete'
? 'LIVE DATA OFF' ? 'LIVE DATA OFF'
: labels[state.terminal] || 'LOAD COMPLETE'; : state.terminal === 'complete' && state.activeIds?.length === 1 && state.activeIds[0] === 'military-installations'
? 'MAPPED SITES LOADED' : labels[state.terminal] || 'LOAD COMPLETE';
return { state: state.terminal, label, detail: '' }; return { state: state.terminal, label, detail: '' };
} }
const active = summary.active; const active = summary.active;
if (active.length === 1 && active[0].installationRetry && !summary.disabling) {
return { state: 'loading', label: active[0].installationRetry.retrying
? 'RETRYING MAPPED SITES' : 'FETCHING MAPPED SITES', detail: 'OpenStreetMap · Overpass' };
}
const elapsed = Math.max(0, nowMs - state.startedAt); const elapsed = Math.max(0, nowMs - state.startedAt);
const label = summary.disabling const label = summary.disabling
? 'TURNING OFF LIVE DATA' ? 'TURNING OFF LIVE DATA'
+36
View File
@@ -1,6 +1,42 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
const retrySite = (stats = {}) => ({ id: 'military-installations', name: 'Mapped Installations', enabled: true,
stats: { status: 'unavailable', error: 'Unavailable', retryAt: Date.now() + 30000, ...stats } });
test('installation retry remains visible after failure dwell without a false spinner', () => {
const summary = aggregateLayerLoading([retrySite()]);
const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 100);
assert.equal(view.state, 'retry');
assert.equal(view.label, 'OVERPASS TEMPORARILY UNAVAILABLE');
assert.match(view.detail, /retrying in 30s/);
assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...retrySite(), enabled: false }]), 100), null);
});
test('an installation retry never conceals another participant failure', () => {
const state = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['military-installations', 'flights'] };
const summary = aggregateLayerLoading([retrySite(), { id: 'flights', enabled: true, stats: { error: 'Failed' } }]);
assert.equal(presentLoadingFeedback(state, summary, 100).label, 'LOAD FAILED');
const healthyNow = aggregateLayerLoading([retrySite()]);
assert.equal(presentLoadingFeedback({ ...state, failedEventIds: ['flights'] }, healthyNow, 100).label, 'LOAD FAILED');
});
test('a fresh installation retry can finish successfully without inheriting the old error', () => {
let state = { ...createLoadingFeedbackState(), phase: 'terminal', terminal: 'error', visible: true, activeIds: ['military-installations'] };
const loading = aggregateLayerLoading([retrySite({ status: 'loading', error: null, loading: true, retryAt: 0, retrying: true })]);
state = reduceLoadingFeedback(state, loading, 1000);
state = reduceLoadingFeedback(state, loading, 1200);
assert.equal(presentLoadingFeedback(state, loading, 1200).label, 'RETRYING MAPPED SITES');
const done = aggregateLayerLoading([retrySite({ status: 'ready', error: null, loading: false, retryAt: 0, retrying: false, count: 3 })]);
state = reduceLoadingFeedback(state, done, 1500);
assert.equal(presentLoadingFeedback(state, done, 1500).label, 'MAPPED SITES LOADED');
});
test('turning off a retrying installation layer does not report the old fetch failure as a disable failure', () => {
const stopping = aggregateLayerLoading([{ ...retrySite(), lifecycleState: 'disabling' }]);
let state = reduceLoadingFeedback(createLoadingFeedbackState(), stopping, 1000);
state = reduceLoadingFeedback(state, stopping, 1200);
const off = aggregateLayerLoading([{ ...retrySite({ status: 'idle', error: null, retryAt: 0 }), enabled: false }]);
state = reduceLoadingFeedback(state, off, 1400);
assert.equal(presentLoadingFeedback(state, off, 1400).label, 'LIVE DATA OFF');
});
import { import {
aggregateLayerLoading, aggregateLayerLoading,
canPresentDeferredStatusNotice, canPresentDeferredStatusNotice,
+3 -1
View File
@@ -10,6 +10,8 @@
// state is re-synced from controller state (never optimistically), so a failed // state is re-synced from controller state (never optimistically), so a failed
// or superseded switch still leaves the truly-active stack lit. // or superseded switch still leaves the truly-active stack lit.
import { keySetupRequirement } from './keySetupCore.mjs';
export const MAP_STACK_CHIP_CLASS = 'map-stack-chip'; export const MAP_STACK_CHIP_CLASS = 'map-stack-chip';
export const PRESENTED_MAP_STACK_IDS = Object.freeze([ export const PRESENTED_MAP_STACK_IDS = Object.freeze([
'photoreal', 'photoreal',
@@ -36,7 +38,7 @@ export function mapStackChipModel(stack, activeId) {
const label = String(stack?.label ?? stack?.id ?? ''); const label = String(stack?.label ?? stack?.id ?? '');
const requiresIon = stack?.requiresIon === true; const requiresIon = stack?.requiresIon === true;
const fallbackReason = requiresIon const fallbackReason = requiresIon
? 'Cesium ion token required' ? keySetupRequirement('cesium-ion')
: `${label || 'This map stack'} is unavailable`; : `${label || 'This map stack'} is unavailable`;
const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason); const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason);
return { return {
+8 -8
View File
@@ -153,7 +153,7 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? { const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? {
...stack, ...stack,
available: false, available: false,
unavailableReason: 'Cesium ion token required for Bing stacks', unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
} : stack)); } : stack));
const selected = []; const selected = [];
renderMapStackChips(container, keyless, { renderMapStackChips(container, keyless, {
@@ -167,10 +167,10 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
assert.equal(bingAerial.getAttribute('aria-disabled'), 'true'); assert.equal(bingAerial.getAttribute('aria-disabled'), 'true');
assert.equal( assert.equal(
bingAerial.getAttribute('aria-label'), bingAerial.getAttribute('aria-label'),
'Bing Aerial unavailable: Cesium ion token required for Bing stacks', 'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
); );
assert.ok(bingAerial.classList.contains('unavailable')); assert.ok(bingAerial.classList.contains('unavailable'));
assert.equal(bingAerial.title, 'Cesium ion token required for Bing stacks'); assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings');
assert.equal(chipText(bingAerial), 'Bing Aerial ION'); assert.equal(chipText(bingAerial), 'Bing Aerial ION');
bingAerial.click(); bingAerial.click();
@@ -186,15 +186,15 @@ test('a non-ion stack that fails never claims an ion token is required', () => {
const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? { const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? {
...stack, ...stack,
available: false, available: false,
unavailableReason: 'Google 3D is unavailable', unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
} : stack)); } : stack));
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc }); renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
const google = container.children[0]; const google = container.children[0];
assert.equal(google.getAttribute('aria-disabled'), 'true'); assert.equal(google.getAttribute('aria-disabled'), 'true');
assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Google 3D is unavailable'); assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion'); assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion');
assert.equal(google.title, 'Google 3D is unavailable'); assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged'); assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged');
}); });
@@ -219,8 +219,8 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
[ [
{ {
requirement: 'ION', requirement: 'ION',
unavailableHint: 'Cesium ion token required', unavailableHint: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
title: 'Cesium ion token required', title: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
}, },
{ {
requirement: '', requirement: '',
+24 -3
View File
@@ -1,5 +1,20 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js'; import { governorRequestRender } from './renderGovernor.js';
import { keySetupRequirement } from './keySetupCore.mjs';
/**
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
* key or ion token configured, the tileset failed for another reason —
* restrictions, quota, an EEA-billed key, or the network — and telling the
* user to add a key they already added is the wrong advice.
* @param {boolean} hasCredentials
* @returns {string}
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials) return 'Google 3D tiles unavailable — check the key\'s API restrictions, quota, or network';
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
}
export const MAP_STACKS = [ export const MAP_STACKS = [
{ {
@@ -140,9 +155,15 @@ export class MapStackController {
* @returns {string} * @returns {string}
*/ */
_unavailableReason(stack) { _unavailableReason(stack) {
return stack?.requiresIon if (stack?.requiresIon) return keySetupRequirement('cesium-ion');
? 'Cesium ion token required for Bing stacks' if (stack?.kind === 'photoreal') return photorealUnavailableReason(this._hasPhotorealCredentials());
: `${stack?.label || 'This map stack'} is unavailable`; return `${stack?.label || 'This map stack'} is unavailable`;
}
/** A direct Google key or an ion token is enough to attempt Google 3D. */
_hasPhotorealCredentials() {
const googleKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
return Boolean(String(googleKey || '').trim()) || Boolean(String(this.cesiumToken || '').trim());
} }
getStack(id) { getStack(id) {
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapStackController, photorealUnavailableReason } from './mapStackController.js';
test('missing photoreal credentials explain both supported setup routes', () => {
assert.match(photorealUnavailableReason(false), /Needs GOOGLE_MAPS_API_KEY.*Provider Settings/);
assert.match(photorealUnavailableReason(false), /Cesium ion token/);
});
test('a configured but failed photoreal route does not ask for another key', () => {
const reason = photorealUnavailableReason(true);
assert.match(reason, /unavailable.*restrictions, quota, or network/);
assert.doesNotMatch(reason, /Needs|add it/);
});
test('controller credential detection accepts ion without a browser global', () => {
const hasCredentials = MapStackController.prototype._hasPhotorealCredentials;
assert.equal(hasCredentials.call({ cesiumToken: 'configured' }), true);
assert.equal(hasCredentials.call({ cesiumToken: ' ' }), false);
});
+2 -2
View File
@@ -4661,9 +4661,9 @@ export class StyleManager {
const searched = await militaryInstallationsLayer.searchNearby?.(); const searched = await militaryInstallationsLayer.searchNearby?.();
if (searched === false) return false; if (searched === false) return false;
const stats = militaryInstallationsLayer.getStats?.(); const stats = militaryInstallationsLayer.getStats?.();
this._showToast(stats?.status === 'zoom-in' this._showToast(stats?.statusMessage || (stats?.status === 'zoom-in'
? 'Zoom in to search mapped installations' ? 'Zoom in to search mapped installations'
: 'Nearby installations refreshed'); : 'Nearby installations refreshed'));
return true; return true;
}, 'Nearby installations could not be refreshed; try again').finally(() => { }, 'Nearby installations could not be refreshed; try again').finally(() => {
button.disabled = false; button.disabled = false;
+3
View File
@@ -1970,6 +1970,9 @@ body.ui-clean-view #scene-runtime.active {
#global-loading-status[data-state='error'] #global-loading-label, #global-loading-status[data-state='error'] #global-loading-label,
#global-loading-status[data-state='error'] #global-loading-detail { color: #ff9b8e; } #global-loading-status[data-state='error'] #global-loading-detail { color: #ff9b8e; }
#global-loading-status[data-state='cancelled'] { color: #ffd488; } #global-loading-status[data-state='cancelled'] { color: #ffd488; }
#global-loading-status[data-state='retry'] { border-color: rgba(255, 212, 136, 0.55); }
#global-loading-status[data-state='retry'] #global-loading-label,
#global-loading-status[data-state='retry'] #global-loading-detail { color: #ffd488; }
#global-loading-status[data-state='cancelled'] #global-loading-label, #global-loading-status[data-state='cancelled'] #global-loading-label,
#global-loading-status[data-state='cancelled'] #global-loading-detail { color: #ffd488; } #global-loading-status[data-state='cancelled'] #global-loading-detail { color: #ffd488; }
#global-loading-status[data-state='acquiring'] { #global-loading-status[data-state='acquiring'] {
+12 -2
View File
@@ -6968,6 +6968,12 @@ function trimMilitaryInstallationCache() {
} }
} }
/** Safe, evidence-based reason for an installation upstream failure. */
export function militaryInstallationFailureReason(error) {
if (['rate_limited', 'timeout', 'query_failed'].includes(error?.installationReason)) return error.installationReason;
return ['AbortError', 'TimeoutError'].includes(error?.name) ? 'timeout' : 'unavailable';
}
function militaryInstallationsProxy() { function militaryInstallationsProxy() {
async function refresh(box, key) { async function refresh(box, key) {
const bbox = `${box.south},${box.west},${box.north},${box.east}`; const bbox = `${box.south},${box.west},${box.north},${box.east}`;
@@ -6977,7 +6983,11 @@ function militaryInstallationsProxy() {
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
); );
if (upstream.status >= 400 || upstream.rateLimited || upstream.runtimeError) { if (upstream.status >= 400 || upstream.rateLimited || upstream.runtimeError) {
throw new Error('Mapped installation upstream unavailable'); throw Object.assign(new Error('Mapped installation upstream unavailable'), {
installationReason: upstream.rateLimited ? 'rate_limited'
: upstream.status === 504 ? 'timeout'
: upstream.runtimeError ? 'query_failed' : 'unavailable',
});
} }
const parsed = JSON.parse(upstream.body); const parsed = JSON.parse(upstream.body);
const elements = Array.isArray(parsed?.elements) const elements = Array.isArray(parsed?.elements)
@@ -7076,7 +7086,7 @@ function militaryInstallationsProxy() {
return; return;
} }
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: 'Mapped installation context is temporarily unavailable' })); res.end(JSON.stringify({ error: 'Mapped installation context is temporarily unavailable', reason: militaryInstallationFailureReason(error) }));
} }
}); });
} }