fix: improve mapped installations and map-source guidance (#183)

Improve mapped installation recovery, deselection, retry feedback, and visual-style and map-source guidance.
This commit is contained in:
Bilawal Sidhu 2026-09-05 03:30:55 -05:00 committed by GitHub
parent 3b6b3f1007
commit 759652207f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 479 additions and 41 deletions

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

View File

@ -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)**

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

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>

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),
);

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';
}

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');
});

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. */

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

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', () => {

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) {

View File

@ -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);
});

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' : '',
};
},

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);

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

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

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'

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,

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 {

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: '',

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) {

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);
});

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;

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'] {

View File

@ -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) }));
}
});
}