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:
parent
3b6b3f1007
commit
759652207f
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
|
|
@ -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)**
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
index.html
14
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,38 @@ function finiteLongitude(value) {
|
|||
function pointFrom(element) {
|
||||
const lat = Number(element?.lat ?? element?.center?.lat);
|
||||
const longitude = Number(element?.lon ?? element?.center?.lon);
|
||||
return finiteLatitude(lat) && finiteLongitude(longitude) ? { latitude: lat, longitude } : null;
|
||||
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
|
||||
|
||||
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
|
||||
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
|
||||
// geometry mode only — `geom` wins and no `center` is ever emitted. Every
|
||||
// way and relation therefore arrived point-less and was dropped here, so the
|
||||
// layer rendered nodes and nothing else (San Diego: 149 of 228 kept, all the
|
||||
// ways and relations gone; Warendorf has no nodes at all, hence an empty
|
||||
// screen over a mapped Bundeswehr barracks). `bounds` accompanies exactly
|
||||
// those elements — including relations, which carry no `geometry` — so it
|
||||
// recovers all of them without touching the query or the footprint path.
|
||||
const bounds = element?.bounds;
|
||||
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
|
||||
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
|
||||
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
|
||||
const south = Number(bounds?.minlat);
|
||||
const west = Number(bounds?.minlon);
|
||||
const north = Number(bounds?.maxlat);
|
||||
const east = Number(bounds?.maxlon);
|
||||
// Ordering matters as much as range: an inverted or antimeridian-spanning
|
||||
// box midpoints to a plausible-looking point in the wrong ocean, so match
|
||||
// isValidInstallationBoundingBox and refuse it rather than average it.
|
||||
// A single installation footprint is never wider than the request bbox cap
|
||||
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
|
||||
// 179) passes the ordering check yet midpoints to longitude 0.
|
||||
if (finiteLatitude(south) && finiteLatitude(north)
|
||||
&& finiteLongitude(west) && finiteLongitude(east)
|
||||
&& south <= north && west <= east
|
||||
&& north - south <= 10 && east - west <= 10) {
|
||||
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function footprintFrom(element) {
|
||||
|
|
|
|||
|
|
@ -74,3 +74,79 @@ test('accepts only small non-dateline request bboxes', () => {
|
|||
assert.equal(isValidInstallationBoundingBox({ south: -1, west: 179, north: 1, east: -179 }), false);
|
||||
assert.equal(isValidInstallationBoundingBox({ south: -20, west: 0, north: 20, east: 1 }), false);
|
||||
});
|
||||
|
||||
test('a way with only bounds is kept, at the midpoint of that box', () => {
|
||||
// The proxy asks for `out center tags geom` and Overpass honours only the
|
||||
// LAST geometry mode, so `center` is never emitted and every way/relation
|
||||
// arrives carrying `bounds` instead. Dropping those rendered nodes and
|
||||
// nothing else — an empty screen over mapped installations.
|
||||
const result = normalizeMilitaryInstallations({ elements: [
|
||||
{ type: 'way', id: 92701457, bounds: { minlat: 51.94, minlon: 7.98, maxlat: 51.96, maxlon: 8.02 },
|
||||
tags: { military: 'barracks', name: 'Sportschule der Bundeswehr' } },
|
||||
] }, '2026-08-28T00:00:00.000Z');
|
||||
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.equal(result.droppedCount, 0);
|
||||
assert.equal(result.records[0].latitude, 51.95);
|
||||
assert.equal(result.records[0].longitude, 8.00);
|
||||
});
|
||||
|
||||
test('a relation with only bounds is kept — it carries no geometry at all', () => {
|
||||
const result = normalizeMilitaryInstallations({ elements: [
|
||||
{ type: 'relation', id: 5, bounds: { minlat: -1, minlon: -2, maxlat: 1, maxlon: 2 },
|
||||
tags: { landuse: 'military' } },
|
||||
] });
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.equal(result.records[0].latitude, 0);
|
||||
assert.equal(result.records[0].longitude, 0);
|
||||
});
|
||||
|
||||
test('an explicit centre still wins over bounds', () => {
|
||||
const result = normalizeMilitaryInstallations({ elements: [
|
||||
{ type: 'way', id: 6, center: { lat: 30.2, lon: -97.7 },
|
||||
bounds: { minlat: 0, minlon: 0, maxlat: 60, maxlon: 60 },
|
||||
tags: { military: 'airfield' } },
|
||||
] });
|
||||
assert.equal(result.records[0].latitude, 30.2);
|
||||
assert.equal(result.records[0].longitude, -97.7);
|
||||
});
|
||||
|
||||
test('an out-of-range or incomplete bounds box is dropped, not averaged', () => {
|
||||
const result = normalizeMilitaryInstallations({ elements: [
|
||||
// latitude past the pole
|
||||
{ type: 'way', id: 1, bounds: { minlat: 80, minlon: 0, maxlat: 95, maxlon: 1 }, tags: { military: 'range' } },
|
||||
// longitude past the antimeridian
|
||||
{ type: 'way', id: 2, bounds: { minlat: 0, minlon: 170, maxlat: 1, maxlon: 181 }, tags: { military: 'range' } },
|
||||
// a half-filled box would average to a plausible-looking lie
|
||||
{ type: 'way', id: 3, bounds: { minlat: 10, maxlat: 12 }, tags: { military: 'range' } },
|
||||
{ type: 'way', id: 4, bounds: {}, tags: { military: 'range' } },
|
||||
{ type: 'way', id: 5, tags: { military: 'range' } },
|
||||
] });
|
||||
assert.deepEqual(result.records, []);
|
||||
assert.equal(result.droppedCount, 5);
|
||||
});
|
||||
|
||||
test('non-numeric bounds never coerce into a mapped site at zero', () => {
|
||||
for (const value of [null, false, '', '0']) {
|
||||
const result = normalizeMilitaryInstallations({ elements: [{
|
||||
type: 'way', id: 90, tags: { military: 'barracks' },
|
||||
bounds: { minlat: value, minlon: value, maxlat: value, maxlon: value },
|
||||
}] });
|
||||
assert.equal(result.records.length, 0);
|
||||
assert.equal(result.droppedCount, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('an inverted or dateline-spanning bounds box is dropped rather than midpointed', () => {
|
||||
const result = normalizeMilitaryInstallations({ elements: [
|
||||
// south above north
|
||||
{ type: 'way', id: 6, bounds: { minlat: 10, minlon: 0, maxlat: 8, maxlon: 1 }, tags: { military: 'range' } },
|
||||
// west east of east: a dateline-spanning box would midpoint to longitude 0
|
||||
{ type: 'way', id: 7, bounds: { minlat: 5, minlon: 179, maxlat: 6, maxlon: -179 }, tags: { military: 'range' } },
|
||||
// ascending but implausibly wide: Overpass reports an antimeridian feature
|
||||
// as minlon -179 / maxlon 179, which also midpoints to longitude 0
|
||||
{ type: 'way', id: 8, bounds: { minlat: 5, minlon: -179, maxlat: 6, maxlon: 179 }, tags: { military: 'range' } },
|
||||
] });
|
||||
assert.deepEqual(result.records, []);
|
||||
assert.equal(result.droppedCount, 3);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' : '',
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'] {
|
||||
|
|
|
|||
|
|
@ -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) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue