diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7398d84..21544fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 809e832..86f69c5 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic,
**[#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) · Featured on **[Pinokio](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g)**
+*“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)**
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index a2bb9da..1f4d861 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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
diff --git a/index.html b/index.html
index f9bc853..e8b0b8d 100644
--- a/index.html
+++ b/index.html
@@ -492,37 +492,37 @@
-
+
◯
Normal
1
-
+
▦
CRT
2
-
+
🌙
NVG
3
-
+
🌡️
FLIR
4
-
+
✦
Anime
5
-
+
◐
Noir
6
-
+
❄
Snow
7
diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs
index f495346..0071475 100644
--- a/scripts/qa-map-source-tray.mjs
+++ b/scripts/qa-map-source-tray.mjs
@@ -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),
);
diff --git a/src/data/installationFeedback.js b/src/data/installationFeedback.js
new file mode 100644
index 0000000..974568a
--- /dev/null
+++ b/src/data/installationFeedback.js
@@ -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';
+}
diff --git a/src/data/installationFeedback.test.mjs b/src/data/installationFeedback.test.mjs
new file mode 100644
index 0000000..26abdb1
--- /dev/null
+++ b/src/data/installationFeedback.test.mjs
@@ -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');
+});
diff --git a/src/data/installationProxy.test.mjs b/src/data/installationProxy.test.mjs
index b43c9fe..08b14de 100644
--- a/src/data/installationProxy.test.mjs
+++ b/src/data/installationProxy.test.mjs
@@ -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. */
diff --git a/src/data/militaryAwareness.js b/src/data/militaryAwareness.js
index 06e6721..3293578 100644
--- a/src/data/militaryAwareness.js
+++ b/src/data/militaryAwareness.js
@@ -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
diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs
index 5b5ae25..43e0c33 100644
--- a/src/data/militaryAwareness.test.mjs
+++ b/src/data/militaryAwareness.test.mjs
@@ -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', () => {
diff --git a/src/data/militaryInstallationData.js b/src/data/militaryInstallationData.js
index b58a9ef..2d78aa7 100644
--- a/src/data/militaryInstallationData.js
+++ b/src/data/militaryInstallationData.js
@@ -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) {
diff --git a/src/data/militaryInstallationData.test.mjs b/src/data/militaryInstallationData.test.mjs
index 4c97421..d820e7f 100644
--- a/src/data/militaryInstallationData.test.mjs
+++ b/src/data/militaryInstallationData.test.mjs
@@ -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);
+});
diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js
index ef2c1d5..26e1fe7 100644
--- a/src/data/militaryInstallations.js
+++ b/src/data/militaryInstallations.js
@@ -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' : '',
};
},
diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs
index 960e572..d002dcc 100644
--- a/src/data/militaryInstallations.test.mjs
+++ b/src/data/militaryInstallations.test.mjs
@@ -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);
diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs
index fbb63d1..030565a 100644
--- a/src/keySetupCore.mjs
+++ b/src/keySetupCore.mjs
@@ -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
diff --git a/src/keySetupCore.test.mjs b/src/keySetupCore.test.mjs
index 70cd56d..3624e84 100644
--- a/src/keySetupCore.test.mjs
+++ b/src/keySetupCore.test.mjs
@@ -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
diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js
index 66b18cd..5ff5dd2 100644
--- a/src/loadingFeedback.js
+++ b/src/loadingFeedback.js
@@ -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'
diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs
index 180da32..51b1da5 100644
--- a/src/loadingFeedback.test.mjs
+++ b/src/loadingFeedback.test.mjs
@@ -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,
diff --git a/src/mapStackChips.js b/src/mapStackChips.js
index 2a5f5ee..c87e319 100644
--- a/src/mapStackChips.js
+++ b/src/mapStackChips.js
@@ -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 {
diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs
index c5420a1..4472bd4 100644
--- a/src/mapStackChips.test.mjs
+++ b/src/mapStackChips.test.mjs
@@ -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: '',
diff --git a/src/mapStackController.js b/src/mapStackController.js
index 9f14740..4da79c9 100644
--- a/src/mapStackController.js
+++ b/src/mapStackController.js
@@ -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) {
diff --git a/src/mapStackUnavailableReason.test.mjs b/src/mapStackUnavailableReason.test.mjs
new file mode 100644
index 0000000..ad4020e
--- /dev/null
+++ b/src/mapStackUnavailableReason.test.mjs
@@ -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);
+});
diff --git a/src/ui.js b/src/ui.js
index 324e291..668e752 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -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;
diff --git a/style.css b/style.css
index b7c3ee1..3941027 100644
--- a/style.css
+++ b/style.css
@@ -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'] {
diff --git a/vite.config.js b/vite.config.js
index 5b2a79a..34dac52 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -6900,6 +6900,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}`;
@@ -6909,7 +6915,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)
@@ -7008,7 +7018,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) }));
}
});
}