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
- 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
+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)
[![#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>
@@ -36,9 +40,9 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
## 🌍 Why This Exists
**You asked, so it's happening.** God's Eye View is open source. Track the world live. Talk to it. Break it. Extend it.
God's Eye View brings public signals into one explorable globe. Track the world live. Talk to it. Break it. Extend it.
Most open-source intelligence is a pile of browser tabs. The signals are abundant, but the *interface* is the bottleneck. God's Eye View turns those signals into a **place**: the world is already broadcasting — flight transponders, ship beacons, orbital elements, seismographs, public cameras — and this makes it visible on a photorealistic 3D Earth in real time. No classified clearance required; it's public signal all the way down, and the interface runs in your browser, under your control.
Flight transponders, ship beacons, orbital elements, seismographs, and public cameras already tell us a lot about the world. God's Eye View puts them in the same place, so you can move between a global picture and an individual aircraft, ship, or street. It runs locally in your browser, with source code you can inspect and extend.
> Half the magic is that it looks like a forbidden cockpit. The other half is that every line of code is inspectable.
@@ -46,9 +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
trajectories are coarse estimates.
You'll be surprised how accessible this is. Free and nearly-free APIs deliver
a surprisingly complete experience out of the box — then it's yours to extend
with bigger data sources whenever you're ready.
Start with the included data sources, then add your own. Each layer is a separate module.
---
@@ -79,48 +81,54 @@ with bigger data sources whenever you're ready.
## ⚡ Quick Start
**Nothing to sign up for to get started.** Both paths below land you in the
same place: a live satellite globe — keyless Esri World Imagery with keyless
terrain, and OSM stepping in automatically if Esri is ever unreachable — with
aircraft, military traffic, satellites, earthquakes, public cameras, radio and
launches already moving on it. No account, no key, no file to edit.
**Start without an account or API keys.** Both paths open the same app with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys.
**Optional signups, optimal experience.** The keyless globe gets you running;
a couple of two-minute signups make it spectacular. Want the photorealistic-3D
cities? A **free Cesium ion token** covers them for eligible personal,
non-commercial use — no Google account needed; current ion terms and quotas
apply. Prefer them straight from Google, plus in-app place search? A
**Google Maps key** is the billing-enabled, metered route — with a surprisingly
generous free tier ([real numbers](#-api-keys)). Either one pastes straight
into **Then power it up** below.
For photorealistic 3D, add a **Cesium ion token** for eligible personal,
non-commercial use, or a **Google Maps key** for the direct, metered route and
in-app place search. Provider terms and quotas apply. Add keys through the
app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options.
### Path 1 — One click, no terminal
1. Install [Pinokio](https://pinokio.computer/).
2. In **Discover → Download from URL**, paste
`https://github.com/bilawalsidhu/gods-eye-view`.
1. Install or update [Pinokio](https://desktop.pinokio.co/) to **8.2 or later**.
2. Open [God's Eye View in Pinokio](https://pinokio.co/apps/github-com-bilawalsidhu-gods-eye-view).
3. Click **Install**, then **Start**.
That is the whole thing. The launcher verifies Pinokio's runtime, installs the
Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports
cross-platform testing of the fixed installer. The launcher installs the
locked dependencies, finds a free local port, and opens the app.
**Tried before and installation failed?** Update Pinokio and try again.
Version 8.2 fixes the launcher installation issue;
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
### Path 2 — Terminal / coding agent
Requires Node.js 24.14.x or 26.x. Node 25 is usable but EOL; the setup doctor
warns instead of blocking it.
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
Node 25, which is end-of-life.
```bash
npm install
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
npm ci
npm run doctor
npm run dev
```
Open **`http://localhost:4173`**. Cold start settles in under two seconds on a
recent laptop (median 1.86 s in a point-in-time M5/Chrome capture —
[docs/PERFORMANCE.md](docs/PERFORMANCE.md); a comparison baseline, not a hardware
requirement). A first-run card offers to stage a mission for you — **Live
Contacts**, **Space Missions**, **Environmental** — or leaves you to explore
manually.
Open **`http://localhost:4173`**. Choose **Live Contacts**, **Space Missions**,
**Environmental**, or **Explore Manually** from the first-run panel.
<details>
<summary>Startup performance</summary>
A point-in-time M5/Chrome capture measured a median 1.86-second cold start.
This is a comparison baseline, not a guarantee for your machine or connection.
See [docs/PERFORMANCE.md](docs/PERFORMANCE.md).
</details>
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
configured keys straight from the Keychain. It starts keyless too.
@@ -136,7 +144,8 @@ reopens the same panel.
- **Where keys land:** Pinokio → the app's ignored `pinokio/ENVIRONMENT`; a
terminal clone → the repo-root `.env`. Either file is made owner-only
*before* a secret is written into it, and it never leaves your machine.
*before* a secret is written into it. These are local plaintext files,
excluded from Git; the app uses your keys to contact the providers.
- **Keys you already have stay yours:** values from your shell or the macOS
Keychain show as *configured externally* and are read-only to the panel.
- **What to get first:** the free [Cesium ion](https://cesium.com/ion) token
@@ -145,12 +154,17 @@ reopens the same panel.
billing-enabled, metered route + place search; OpenAI when you want to talk
to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
> [!WARNING]
> Do not enter credentials in Pinokio 8.0.40's native **Configure** panel: that
> release does not save this nested app file correctly, and it logs submitted
> values. Use Provider Settings inside the app instead. Both file stores are
> local plaintext; on macOS the Keychain via `./scripts/dev-fresh.sh` remains
> the stronger option.
<details>
<summary>Older Pinokio versions and credential storage</summary>
Do not enter credentials in Pinokio 8.0.40's native **Configure** panel: that
release does not save this nested app file correctly, and it logs submitted
values. Use **POWER UP → Provider Settings** inside GEV instead. The Pinokio
8.2 announcement fixes installation; it does not establish that this separate
Configure issue is resolved. On macOS, the Keychain via
`./scripts/dev-fresh.sh` remains the stronger storage option.
</details>
The server binds to **localhost** on both paths, and Provider Settings answers
requests only from your machine. Browser-side keys (Google Maps, Cesium ion)
@@ -161,7 +175,7 @@ and it carries the LAN-sharing rules alongside [Keys & Costs](#-api-keys).
## 🕐 The First Five Minutes
No account, no signup. The first-run card will offer to stage a mission for you — or run this gauntlet yourself. Somewhere in these five minutes it stops feeling like a demo:
Choose a first-run mission, or try these in order. The GIFs show Google Photorealistic 3D; your starting basemap depends on the keys you've added.
1. **Light up the sky.** Take the **Live Contacts** mission (or turn on **Flights** yourself) — thousands of live aircraft, gliding on real telemetry, detection mesh already reading the scene. Click one: the camera locks on, a trail draws behind it, and its live telemetry card comes up.
2. **Take the controls.** Hit **COCKPIT** on your tracked plane and ride it down, switching sensors mid-flight: NVG into Ironbow FLIR.
@@ -248,7 +262,7 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod
## 🛰️ What's on the Globe
Thirteen live layers. **Eleven of them need nothing at all** — no key, no account, no signup, starting with the satellite basemap you land on. (🟢 nothing · 🟡 free key · 🔴 metered.)
Thirteen layers and map sources. **Eleven have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth |
|-------|--------------|--------|------|
@@ -258,7 +272,7 @@ Thirteen live layers. **Eleven of them need nothing at all** — no key, no acco
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **Traffic** | Live congestion driving per-vehicle flow at street level — dive below ~8 km and the dots color to real jams. Keyless it's an approximate simulation | TomTom + OSM | 🟢 (🟡 TomTom makes it real — get one) |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
@@ -322,14 +336,14 @@ Once the basics click, run these:
## 🔧 Under the Hood
Some of the engineering that makes it feel real rather than like a tech demo:
How the globe handles live data:
- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
- **Smooth motion from choppy data.** Live feeds arrive every 15–30s; the globe renders one interval behind real time and interpolates between known fixes. Dead reckoning fills the gaps.
- **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker.
- **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating.
- **Spends your quota like it's its own.** The paid feeds run behind cached, budget-governed proxies — an OpenSky credit governor, a TomTom daily tile budget, disk-cached TLEs — so an afternoon of exploring doesn't torch an API allowance.
- **Secure by design.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
- **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls.
- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
```
@@ -339,9 +353,9 @@ src/
├── hud.js # Intelligence HUD + AI scene summary
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
├── iconOrientation.js # Screen-projected world-space headings + horizon cull
├── voice/ # OpenAI Realtime session + 28 voice tools
├── data/ # One module per layer + orchestration + context store
│ ├── iconOrientation.js # Screen-projected headings + horizon cull
│ └── local_data/ # Bundled datasets (per-folder provenance)
└── scenes/ # Cinematic scene director
```
@@ -352,17 +366,14 @@ See [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md) for the authoritative runti
## 🔑 API Keys
**The legend, one more time:** 🟢 **no signup** — works out of the box · 🟡 **free key** — register, paste, done · 🔴 **metered** — a billing-enabled account; costs are small but real.
🟢 **No key** · 🟡 **Free key** · 🔴 **Metered**
Most of the globe is 🟢: flights (anonymous), military traffic, satellites, earthquakes, CCTV, radio, bikeshare, space missions, mapped installations, and every bundled dataset run with **zero keys**.
Use **POWER UP → Provider Settings** to add keys. The tables below explain what
each provider enables; none is required to start. See the
[setup instructions](#then-power-it-up--in-the-app-not-in-a-file) for storage
and configuration details.
**And you never have to edit a file to add one.** Click **POWER UP** in the
bottom-right corner of the running app, paste the key into Provider Settings,
hit **SAVE KEYS** — the app writes it to its own local store with owner-only
permissions and restarts itself. Everything below is the map of what each key
actually buys you.
### What you need for the good experience
### Choose the capabilities you want
Six keys. Four have a free tier, and the two 🔴 ones are metered:
@@ -371,9 +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/) |
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free, seriously, it's a two-minute signup |
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
| 🟡 | **TomTom** | 🚦 Real traffic instead of an approximate simulation | [developer.tomtom.com](https://developer.tomtom.com) — free tier is plenty, completely worth it |
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
![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) |
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
All of them are worth getting. None of them are required to start.
Add these if you need higher polling allowances.
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
where each configured provider was found without printing credential values.
@@ -396,7 +407,10 @@ OpenSky summary reports only OAuth client-pair presence, not the resolved
runtime mode or credential validity; Basic and credentials-file modes remain
advanced `dev-fresh.sh` configuration.
**If you'd rather not use the panel** — headless boxes, coding agents, scripted setups:
<details>
<summary>Advanced setup: environment variables and macOS Keychain</summary>
For headless machines, coding agents, or scripted setups:
```bash
# Put keys in .env (see .env.example), or pass them as env vars:
@@ -412,6 +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`.
</details>
### 💸 What it actually costs
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
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **set provider-side budget caps** (Google Cloud budgets, OpenAI usage limits): the throttles are app-level guards, not billing caps. Full threat model in [SECURITY.md](SECURITY.md).
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
Provider Settings switches itself off whenever the server is shared. The panel
answers loopback requests only, and any sharing mode disables the surface
outright rather than trusting the socket — tunnelled traffic reaches the server
from loopback too, so socket identity can't carry that boundary. Nobody on your
LAN gets a key-entry form.
Provider Settings is disabled when the server is shared, so remote users cannot
access the key-entry panel.
Pinokio LAN and Cloudflare sharing are currently unavailable for this launcher.
The supported Pinokio release can activate sharing again when the Open-action
URL is registered, and writes a successful tunnel-login passcode into its own
notification and terminal stream. Before preflight, the launcher rewrites both
sharing modes to disabled values, clears the child passcode, and pins Pinokio's
share trigger to a disabled sentinel. The app then starts loopback-only and
registers the standard Open URL. Use a separate reviewed authentication proxy
if remote access is required.
**Pinokio LAN and Cloudflare sharing remain disabled for this launcher.** Use
a separately reviewed authentication proxy if remote access is required.
[SECURITY.md](SECURITY.md) explains the restrictions and threat model.
---
+19
View File
@@ -2,6 +2,25 @@
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`,
> `#first-run-launcher`, styles at the tail of `style.css`). After startup
> 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">
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open"><img src="/pin.svg" alt="" /></button>
<div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal">
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter.">
<span class="btn-icon">◯</span>
<span class="btn-label">Normal</span>
<span class="btn-key">1</span>
</button>
<button class="style-btn" data-style="retro">
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature.">
<span class="btn-icon">▦</span>
<span class="btn-label">CRT</span>
<span class="btn-key">2</span>
</button>
<button class="style-btn" data-style="surveillance">
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette.">
<span class="btn-icon">🌙</span>
<span class="btn-label">NVG</span>
<span class="btn-key">3</span>
</button>
<button class="style-btn" data-style="thermal">
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color.">
<span class="btn-icon">🌡️</span>
<span class="btn-label">FLIR</span>
<span class="btn-key">4</span>
</button>
<button class="style-btn" data-style="anime">
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines.">
<span class="btn-icon">✦</span>
<span class="btn-label">Anime</span>
<span class="btn-key">5</span>
</button>
<button class="style-btn" data-style="noir">
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading.">
<span class="btn-icon">◐</span>
<span class="btn-label">Noir</span>
<span class="btn-key">6</span>
</button>
<button class="style-btn" data-style="snow">
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene.">
<span class="btn-icon">❄</span>
<span class="btn-label">Snow</span>
<span class="btn-key">7</span>
+5 -2
View File
@@ -309,7 +309,8 @@ try {
'key-required sources stay focusable, explained, and inert when no ion token is configured',
ionSource.ariaDisabled === 'true'
&& 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
&& JSON.stringify(ionSource.active) === JSON.stringify([activeBeforeIonAttempt]),
JSON.stringify(ionSource),
@@ -736,7 +737,9 @@ try {
check(
`a map=${legacyId} link restores to the best available fallback with its tile lit`,
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),
);
+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 {
militaryInstallationCacheKey,
militaryInstallationFailureReason,
militaryInstallationDiskFresh,
militaryInstallationDiskPath,
migrateMilitaryInstallationEntry,
@@ -27,6 +28,12 @@ import {
} from '../../vite.config.js';
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;
/** 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
// read `enabling` across 34 samples with the panel non-numeric throughout,
// 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
// 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
// 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
@@ -348,7 +348,9 @@ function isSame(subject, item, prefix, key) {
*/
export function summarizeInstallationViewport(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 {
...summary,
reason: summary.count
+5
View File
@@ -1530,6 +1530,11 @@ test('installation summaries disclose viewport-scoped coverage', () => {
const unavailable = summarizeInstallationViewport([], { available: false, stale: false });
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', () => {
+32 -1
View File
@@ -54,7 +54,38 @@ function finiteLongitude(value) {
function pointFrom(element) {
const lat = Number(element?.lat ?? element?.center?.lat);
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) {
@@ -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: -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 {
clearSelectedEntityContextForLayer,
getSelectedEntityContext,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
@@ -17,6 +18,7 @@ import {
// sequentially so overlapping renders cannot stack requests on the proxy.
import { warmFireAnchorFloors } from './fireAnchors.js';
import { normalizeMilitaryInstallations } from './militaryInstallationData.js';
import { installationFeedback } from './installationFeedback.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
const LAYER_ID = 'military-installations';
@@ -74,6 +76,8 @@ const state = {
retryTimer: null,
/** Current backoff step for that retry; 0 = next failure starts at the minimum. */
retryDelayMs: 0,
retryAt: 0,
failureReason: null,
moveEndRemove: null,
clickHandler: null,
timer: null,
@@ -239,7 +243,13 @@ function renderableRecords() {
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
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
governorRequestRender('installations-render');
@@ -339,7 +349,7 @@ function selectRecord(id) {
const record = state.recordById.get(id);
if (!record || !state.dataSource) return false;
state.selectedId = id;
renderRecords();
renderRecords({ claimSelection: true });
// renderRecords drops selectedId when the record produced no entity.
return state.selectedId === id;
}
@@ -351,7 +361,16 @@ function installInteraction(viewer) {
if (!state.enabled) return;
const picked = viewer.scene.pick(click.position);
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);
}
@@ -379,8 +398,10 @@ function scheduleUnavailableRetry() {
if (!state.enabled) return;
clearTimeout(state.retryTimer);
state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs);
state.retryAt = Date.now() + state.retryDelayMs;
state.retryTimer = setTimeout(() => {
state.retryTimer = null;
state.retryAt = 0;
if (state.enabled && !state.loading) loadInstallations();
}, state.retryDelayMs);
}
@@ -388,6 +409,7 @@ function scheduleUnavailableRetry() {
function clearUnavailableRetry({ resetBackoff = true } = {}) {
clearTimeout(state.retryTimer);
state.retryTimer = null;
state.retryAt = 0;
if (resetBackoff) state.retryDelayMs = 0;
}
@@ -415,13 +437,18 @@ async function loadInstallations() {
const requestAbort = new AbortController();
state.abort = requestAbort;
state.loading = true;
clearUnavailableRetry({ resetBackoff: false });
// The previous attempt's failure is not the outcome of this new attempt.
setInstallationStatus('loading');
try {
const fetchInstallations = async (exact) => {
const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)]));
if (exact) query.set('exact', '1');
const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal });
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;
};
@@ -491,6 +518,7 @@ async function loadInstallations() {
// Even the exact-viewport retry can saturate in a dense area. Say so rather
// than implying the view is completely surveyed.
state.saturated = saturated;
state.failureReason = null;
clearUnavailableRetry();
setInstallationStatus(
state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty',
@@ -502,6 +530,7 @@ async function loadInstallations() {
warmInstallationFloors(state.records);
} catch (error) {
if (error?.name === 'AbortError') return;
state.failureReason = error?.failureReason || 'unavailable';
setInstallationStatus('unavailable', error?.message || 'Installation context unavailable');
scheduleUnavailableRetry();
} finally {
@@ -545,6 +574,8 @@ const militaryInstallationsLayer = {
if (state.dataSource) state.dataSource.show = false;
clearSelectedEntityContextForLayer(LAYER_ID);
state.selectedId = null;
state.failureReason = null;
setInstallationStatus('idle');
},
update() { return loadInstallations(); },
/** Request a one-shot Google Maps Places search around the current map view. */
@@ -632,6 +663,10 @@ const militaryInstallationsLayer = {
error: state.error,
status: state.status,
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' : '',
};
},
+71 -2
View File
@@ -23,6 +23,62 @@ import {
installRenderGovernor,
} from '../renderGovernor.js';
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', () => {
const oneDegree = approximateSurfaceDistanceM(0, 0, 0, 1);
@@ -233,6 +289,8 @@ async function runInstallationLoad({
};
const dataSources = [];
const cameraFlights = [];
let picked = null;
let clickAction;
const viewer = {
camera: {
moveEnd: { addEventListener() { return () => {}; } },
@@ -249,7 +307,7 @@ async function runInstallationLoad({
scene: {
canvas: { addEventListener() {}, removeEventListener() {} },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
pick() { return null; },
pick() { return picked; },
// Enough surface for the real render governor to drive this viewer, so
// one-shot render requests are observable.
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);
militaryInstallationsLayer.enable();
await militaryInstallationsLayer.update();
@@ -277,6 +341,11 @@ async function runInstallationLoad({
entities: () => dataSources[0]?.entities?.values || [],
contextLabels: () => contextEvents,
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),
restore() {
militaryInstallationsLayer.destroy(viewer);
+7
View File
@@ -263,6 +263,13 @@ export function knownKeySetupEnvVars() {
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
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
+9
View File
@@ -8,12 +8,21 @@ import {
commandCompletedSuccessfully,
isKeySetupExternallyManaged,
keySetupStatus,
keySetupRequirement,
knownKeySetupEnvVars,
parseWindowsUserSid,
upsertDotenvValues,
validateKeySetupUpdates,
} 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', () => {
// 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
+37 -5
View File
@@ -1,3 +1,5 @@
import { installationFeedback } from './data/installationFeedback.js';
export const LOADING_REVEAL_DELAY_MS = 160;
export const LOADING_TERMINAL_DWELL_MS = 2200;
export const LOADING_FAILURE_DWELL_MS = 5000;
@@ -17,10 +19,11 @@ export function normalizeLayerLoading(layer = {}) {
const disabling = lifecycleState === 'disabling';
const loading = lifecycleState === 'enabling' || disabling || stats.loading === true || stats.refreshing === true;
const count = finiteCount(stats.count);
const error = stats.error || stats.lastError || stats.managerRefreshError || null;
const unavailable = stats.unavailable === true
const stoppingInstallations = layer.id === 'military-installations' && disabling;
const error = stoppingInstallations ? null : stats.error || stats.lastError || stats.managerRefreshError || null;
const unavailable = !stoppingInstallations && (stats.unavailable === true
|| stats.available === false
|| ['unavailable', 'offline', 'down', 'error'].includes(status);
|| ['unavailable', 'offline', 'down', 'error'].includes(status));
const keyRequired = stats.keyRequired === true || stats.missingKey === true;
const degraded = stats.degraded === true || Boolean(error);
const accepted = Boolean(stats.lastUpdate) || count > 0;
@@ -37,6 +40,10 @@ export function normalizeLayerLoading(layer = {}) {
unavailable,
keyRequired,
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) {
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;
}
@@ -244,6 +251,10 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
batchOutcome,
terminal: null,
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,
batchOutcome: 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. */
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.phase === 'terminal') {
const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', error: 'LOAD FAILED' };
const label = state.operation === 'disabling' && state.terminal === 'complete'
? '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: '' };
}
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 label = summary.disabling
? 'TURNING OFF LIVE DATA'
+36
View File
@@ -1,6 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 {
aggregateLayerLoading,
canPresentDeferredStatusNotice,
+3 -1
View File
@@ -10,6 +10,8 @@
// state is re-synced from controller state (never optimistically), so a failed
// 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 PRESENTED_MAP_STACK_IDS = Object.freeze([
'photoreal',
@@ -36,7 +38,7 @@ export function mapStackChipModel(stack, activeId) {
const label = String(stack?.label ?? stack?.id ?? '');
const requiresIon = stack?.requiresIon === true;
const fallbackReason = requiresIon
? 'Cesium ion token required'
? keySetupRequirement('cesium-ion')
: `${label || 'This map stack'} is unavailable`;
const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason);
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 ? {
...stack,
available: false,
unavailableReason: 'Cesium ion token required for Bing stacks',
unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
} : stack));
const selected = [];
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-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.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');
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' ? {
...stack,
available: false,
unavailableReason: 'Google 3D is unavailable',
unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
} : stack));
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
const google = container.children[0];
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(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');
});
@@ -219,8 +219,8 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
[
{
requirement: 'ION',
unavailableHint: 'Cesium ion token required',
title: 'Cesium ion token required',
unavailableHint: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
title: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
},
{
requirement: '',
+24 -3
View File
@@ -1,5 +1,20 @@
import * as Cesium from 'cesium';
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 = [
{
@@ -140,9 +155,15 @@ export class MapStackController {
* @returns {string}
*/
_unavailableReason(stack) {
return stack?.requiresIon
? 'Cesium ion token required for Bing stacks'
: `${stack?.label || 'This map stack'} is unavailable`;
if (stack?.requiresIon) return keySetupRequirement('cesium-ion');
if (stack?.kind === 'photoreal') return photorealUnavailableReason(this._hasPhotorealCredentials());
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) {
+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?.();
if (searched === false) return false;
const stats = militaryInstallationsLayer.getStats?.();
this._showToast(stats?.status === 'zoom-in'
this._showToast(stats?.statusMessage || (stats?.status === 'zoom-in'
? 'Zoom in to search mapped installations'
: 'Nearby installations refreshed');
: 'Nearby installations refreshed'));
return true;
}, 'Nearby installations could not be refreshed; try again').finally(() => {
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-detail { color: #ff9b8e; }
#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-detail { color: #ffd488; }
#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() {
async function refresh(box, key) {
const bbox = `${box.south},${box.west},${box.north},${box.east}`;
@@ -6977,7 +6983,11 @@ function militaryInstallationsProxy() {
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
);
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 elements = Array.isArray(parsed?.elements)
@@ -7076,7 +7086,7 @@ function militaryInstallationsProxy() {
return;
}
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) }));
}
});
}