mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into feat/tools-area-breakdowns
This commit is contained in:
13 files changed
+956
-20
No files matched your search
@@ -73,7 +73,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
|||||||
- **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it.
|
- **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it.
|
||||||
- **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback.
|
- **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback.
|
||||||
- **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered.
|
- **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered.
|
||||||
- **🔍 Keyless search:** Type coordinates or a bundled city or landmark to fly there with no network request; other names use Google when configured, then Photon and Nominatim.
|
- **🔍 Keyless search:** Type decimal coordinates, a two-dimensional WGS84 `geo:` URI (for example, `geo:39.7392,-104.9903`), or a bundled city or landmark to fly there with no geocoder request; other names use Google when configured, then Photon and Nominatim.
|
||||||
- **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs.
|
- **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs.
|
||||||
- **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)).
|
- **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with
|
||||||
|
the existing coordinate framing and label. Unsupported URI fields are passed
|
||||||
|
to the remaining search providers unchanged (cj-vana, #1071).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain
|
||||||
|
at once, but no longer lasts the whole session: while the fallback is on
|
||||||
|
screen, the real provider is retried in the background (30 seconds, doubling
|
||||||
|
to a 10-minute cap) and replaces it live once it loads. Map switches never
|
||||||
|
wait for a retry (Aditya, @Rohilalala, #1111).
|
||||||
+14
-4
@@ -390,7 +390,12 @@ doubling to a 15 s cap, with every retry waiting behind one shared cooldown
|
|||||||
plus up to 1.5 s of jitter, and `Retry-After` extending the cooldown when the
|
plus up to 1.5 s of jitter, and `Retry-After` extending the cooldown when the
|
||||||
upstream exposes it. One console warning is logged per cooldown window. Other
|
upstream exposes it. One console warning is logged per cooldown window. Other
|
||||||
failures keep Cesium's handling and the flat `EllipsoidTerrainProvider`
|
failures keep Cesium's handling and the flat `EllipsoidTerrainProvider`
|
||||||
fallback. `scripts/qa-terrain-429.mjs` reproduces the burst against a keyless
|
fallback; while that flat terrain is on a shown globe the map controller
|
||||||
|
retries Re:Earth in the background (30 s, doubling to a 10-minute cap, each
|
||||||
|
attempt abandoned after 60 s) and installs the real terrain without waiting
|
||||||
|
for a map switch or an imagery retry. Behind a 3D tileset it neither retries
|
||||||
|
nor installs; the next globe activation does.
|
||||||
|
`scripts/qa-terrain-429.mjs` reproduces the burst against a keyless
|
||||||
dev server, counts real throttles with their headers as evidence, synthesises
|
dev server, counts real throttles with their headers as evidence, synthesises
|
||||||
throttles with `--inject N` when the upstream is not throttling, and fails if
|
throttles with `--inject N` when the upstream is not throttling, and fails if
|
||||||
a throttled tile never loads.
|
a throttled tile never loads.
|
||||||
@@ -4726,12 +4731,17 @@ Coordinate input requires exactly two nonempty components; extra comma or semico
|
|||||||
|
|
||||||
Location search/fly-to, annotations and Radio location lookup receive one
|
Location search/fly-to, annotations and Radio location lookup receive one
|
||||||
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
||||||
two offline providers first — a strict decimal-degree coordinate reader and an
|
two offline providers first — a strict decimal-degree/geo-URI coordinate reader and an
|
||||||
exact-match reader over the bundled city and landmark names — then Google when
|
exact-match reader over the bundled city and landmark names — then Google when
|
||||||
configured and Photon/OpenStreetMap as fallback, including Google transport
|
configured and Photon/OpenStreetMap as fallback, including Google transport
|
||||||
failures or declined requests. The offline pair answer with no request and no
|
failures or declined requests. The offline pair answer with no request and no
|
||||||
key, and decline everything they are not certain of: a coordinate must be a
|
key, and decline everything they are not certain of: a coordinate must be a
|
||||||
decimal-degree pair (either order when N/S/E/W fix the axes), and a bundled name
|
decimal-degree pair (either order when N/S/E/W fix the axes), or a two-dimensional
|
||||||
|
WGS84 `geo:latitude,longitude` URI with an optional `;crs=wgs84`. URI scheme and
|
||||||
|
CRS names are case-insensitive; internal whitespace, altitude, uncertainty, other
|
||||||
|
parameters, query strings and fragments decline rather than silently dropping
|
||||||
|
information. For example, `geo:39.7392,-104.9903` resolves offline to the same exact
|
||||||
|
point, decimal label and framing box as `39.7392, -104.9903`. A bundled name
|
||||||
must match exactly, so `12junk, 34oops` and `austin, tx` both reach the
|
must match exactly, so `12junk, 34oops` and `austin, tx` both reach the
|
||||||
geocoders instead of being answered from the bundle. Degrees/minutes/seconds and
|
geocoders instead of being answered from the bundle. Degrees/minutes/seconds and
|
||||||
grid references are not parsed. An answer either provider gives is exact, so
|
grid references are not parsed. An answer either provider gives is exact, so
|
||||||
@@ -4788,7 +4798,7 @@ are omitted rather than framing the wrong part of the globe.
|
|||||||
- The bottom Visual Presets tray presents a **five-tile MAP SOURCE row** (`#map-stack-chips`, `src/mapStackChips.js`): Google 3D, Esri Satellite, Bing Aerial, Bing Labels, and OSM. The duplicate left `#stack-panel` is retired. The five tiles share one row on desktop and two rows on narrow screens, carry `aria-pressed` on the active source, and remain keyboard-reachable with a visible focus outline.
|
- The bottom Visual Presets tray presents a **five-tile MAP SOURCE row** (`#map-stack-chips`, `src/mapStackChips.js`): Google 3D, Esri Satellite, Bing Aerial, Bing Labels, and OSM. The duplicate left `#stack-panel` is retired. The five tiles share one row on desktop and two rows on narrow screens, carry `aria-pressed` on the active source, and remain keyboard-reachable with a visible focus outline.
|
||||||
- The lit tile follows controller state, not the click: a rejected switch (no ion token) or a superseded one (rapid A→B) leaves the genuinely active source lit, and the tray heading keeps its short-label status readout (`...` while switching, amber on `lastError`).
|
- The lit tile follows controller state, not the click: a rejected switch (no ion token) or a superseded one (rapid A→B) leaves the genuinely active source lit, and the tray heading keeps its short-label status readout (`...` while switching, amber on `lastError`).
|
||||||
- Ion stacks remain visible and keyboard-focusable when no ion token is configured, but expose `aria-disabled="true"` and do not switch. Their accessible label and tooltip quote `getStacks().unavailableReason` — the same string `setStack()` puts in the toast. OSM works keyless. The `ION` badge is gated on the stack's own `requiresIon`, so a `photoreal` chip unavailable because the Google tileset failed says so instead of falsely demanding an ion token.
|
- Ion stacks remain visible and keyboard-focusable when no ion token is configured, but expose `aria-disabled="true"` and do not switch. Their accessible label and tooltip quote `getStacks().unavailableReason` — the same string `setStack()` puts in the toast. OSM works keyless. The `ION` badge is gated on the stack's own `requiresIon`, so a `photoreal` chip unavailable because the Google tileset failed says so instead of falsely demanding an ion token.
|
||||||
- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path.
|
- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable; while that construction fallback is on screen Esri is retried in the background, 30 s doubling to a 10-minute cap with each attempt abandoned after 60 s, and replaces OSM once it loads. The switch to OSM after repeated Esri tile failures is not retried; selecting Esri again tries it). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path.
|
||||||
|
|
||||||
### Voice Map Whiteboard / Annotations (June 2026)
|
### Voice Map Whiteboard / Annotations (June 2026)
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,63 @@ try {
|
|||||||
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
|
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const geocoderRequests = [];
|
||||||
|
const recordGeocoderRequest = (request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
if (
|
||||||
|
url.pathname === '/api/geocode' ||
|
||||||
|
url.pathname === '/api/google/geocode' ||
|
||||||
|
(url.hostname === 'photon.komoot.io' && url.pathname === '/api/')
|
||||||
|
)
|
||||||
|
geocoderRequests.push(request.url());
|
||||||
|
};
|
||||||
|
page.on('request', recordGeocoderRequest);
|
||||||
|
await page.$eval('#location-search', (input) => {
|
||||||
|
input.value = 'geo:39.7392,-104.9903';
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const state =
|
||||||
|
window.__godsEyeView.styleManager._locationLookup.getState();
|
||||||
|
return (
|
||||||
|
state.query === 'geo:39.7392,-104.9903' && state.status === 'found'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'native geo URI search shows the decimal coordinate readout',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const ui = window.__godsEyeView.styleManager;
|
||||||
|
return (
|
||||||
|
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
|
||||||
|
ui._locationMiniCity.textContent.includes('39.7392° N') &&
|
||||||
|
ui._locationMiniPoi.textContent === '104.9903° W'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'geo URI navigation settles near the requested coordinate',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const position = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||||
|
const degrees = 180 / Math.PI;
|
||||||
|
return (
|
||||||
|
Math.abs(position.latitude * degrees - 39.7392) < 0.05 &&
|
||||||
|
Math.abs(position.longitude * degrees + 104.9903) < 0.05
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'geo URI search makes no geocoder request',
|
||||||
|
geocoderRequests.length === 0,
|
||||||
|
);
|
||||||
|
page.off('request', recordGeocoderRequest);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const lookup = window.__godsEyeView.styleManager._locationLookup;
|
const lookup = window.__godsEyeView.styleManager._locationLookup;
|
||||||
window.__qaSearchRequests = [];
|
window.__qaSearchRequests = [];
|
||||||
@@ -96,6 +153,7 @@ try {
|
|||||||
window.__qaSearchRequests.push({ query, options, resolve }),
|
window.__qaSearchRequests.push({ query, options, resolve }),
|
||||||
);
|
);
|
||||||
document.getElementById('location-search').value = 'First';
|
document.getElementById('location-search').value = 'First';
|
||||||
|
document.getElementById('location-search').focus();
|
||||||
});
|
});
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
|
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
|
||||||
|
|||||||
+191
-8
@@ -9,6 +9,18 @@ import { acquireImageryComparison } from './imageryComparison.js';
|
|||||||
*/
|
*/
|
||||||
const AUTOMATIC = Symbol('automatic switch');
|
const AUTOMATIC = Symbol('automatic switch');
|
||||||
|
|
||||||
|
/** Background retries of a fallback in use back off from 30 s to 10 min. */
|
||||||
|
const FALLBACK_RETRY_MS = 30_000;
|
||||||
|
const FALLBACK_RETRY_MAX_MS = 600_000;
|
||||||
|
/** A retry attempt still unsettled after a minute counts as failed. */
|
||||||
|
const FALLBACK_ATTEMPT_MS = 60_000;
|
||||||
|
|
||||||
|
const resolved = (provider, effectiveStackId) => ({
|
||||||
|
provider,
|
||||||
|
effectiveStackId,
|
||||||
|
fallbackMessage: null,
|
||||||
|
});
|
||||||
|
|
||||||
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
|
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
|
||||||
export class MapSourceController {
|
export class MapSourceController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -46,8 +58,18 @@ export class MapSourceController {
|
|||||||
this._lastError = null;
|
this._lastError = null;
|
||||||
this._imageryLayer = null;
|
this._imageryLayer = null;
|
||||||
this._activeImageryProvider = null;
|
this._activeImageryProvider = null;
|
||||||
|
this._activeResolution = null;
|
||||||
this._removeImageryErrorListener = null;
|
this._removeImageryErrorListener = null;
|
||||||
this._terrainMode = null;
|
this._terrainMode = null;
|
||||||
|
this._terrainFallback = null;
|
||||||
|
this._retryTimer = null;
|
||||||
|
this._retrying = false;
|
||||||
|
this._retryRequested = false;
|
||||||
|
this._retryDelay = FALLBACK_RETRY_MS;
|
||||||
|
/** Expire callbacks of retry attempts in flight; destroy() runs them. */
|
||||||
|
this._retryDeadlines = new Set();
|
||||||
|
/** A fallback terrain a retry replaced while it stayed installed. */
|
||||||
|
this._replacedTerrain = null;
|
||||||
this._subscribers = new Set();
|
this._subscribers = new Set();
|
||||||
this._destroyed = false;
|
this._destroyed = false;
|
||||||
}
|
}
|
||||||
@@ -285,21 +307,171 @@ export class MapSourceController {
|
|||||||
this._showTileset(null);
|
this._showTileset(null);
|
||||||
this.viewer.scene.globe.show = true;
|
this.viewer.scene.globe.show = true;
|
||||||
this._activeId = resolution.effectiveStackId;
|
this._activeId = resolution.effectiveStackId;
|
||||||
|
this._activeResolution = resolution;
|
||||||
if (source?.terrain && source.terrain.id !== this._terrainMode) {
|
if (source?.terrain && source.terrain.id !== this._terrainMode) {
|
||||||
const terrain = source.terrain;
|
const terrain = source.terrain;
|
||||||
const result = await this._cached(
|
const pending = this._cached(this._terrainProviders, terrain.id, () =>
|
||||||
this._terrainProviders,
|
terrain.create({ signal: this._abort.signal }),
|
||||||
terrain.id,
|
|
||||||
() => terrain.create({ signal: this._abort.signal }),
|
|
||||||
);
|
);
|
||||||
|
let result = await pending;
|
||||||
if (gen !== this._switchGen) return;
|
if (gen !== this._switchGen) return;
|
||||||
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
|
// A background retry may have replaced a fallback while this waited.
|
||||||
else this.viewer.terrainProvider = result.provider;
|
const latest = this._terrainProviders.get(terrain.id);
|
||||||
|
if (latest !== pending) result = await latest;
|
||||||
|
if (gen !== this._switchGen) return;
|
||||||
|
this._applyTerrain(result);
|
||||||
this._terrainMode = terrain.id;
|
this._terrainMode = terrain.id;
|
||||||
}
|
}
|
||||||
|
if (resolution.fallbackFor || this._terrainFallback) this._scheduleRetry();
|
||||||
return resolution;
|
return resolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_applyTerrain(result) {
|
||||||
|
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
|
||||||
|
else this.viewer.terrainProvider = result.provider;
|
||||||
|
this._terrainFallback = result.fallback ? result : null;
|
||||||
|
if (this._replacedTerrain && this._replacedTerrain !== result) {
|
||||||
|
this._dispose(this._replacedTerrain.provider);
|
||||||
|
this._replacedTerrain = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One timer retries the real provider behind whichever fallback is on
|
||||||
|
* screen: Esri's OSM stand-in, or flat terrain on a shown globe. Fallbacks
|
||||||
|
* stay cached and shown meanwhile, so no switch waits on a retry; a
|
||||||
|
* fallback out of use is left alone until an activation shows it again.
|
||||||
|
* One retry runs at a time; a request made meanwhile re-arms once it ends.
|
||||||
|
*/
|
||||||
|
_scheduleRetry() {
|
||||||
|
if (this._retryTimer || this._destroyed) return;
|
||||||
|
if (this._retrying) {
|
||||||
|
this._retryRequested = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._retryTimer = setTimeout(() => {
|
||||||
|
this._retryTimer = null;
|
||||||
|
void this._retryFallbacks();
|
||||||
|
}, this._retryDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _retryFallbacks() {
|
||||||
|
const resolution = this._activeResolution;
|
||||||
|
const flat = this.viewer.scene.globe.show ? this._terrainFallback : null;
|
||||||
|
const id = resolution?.fallbackFor;
|
||||||
|
let terrain = flat && this._sources.get(this._activeId)?.terrain;
|
||||||
|
if (terrain?.id !== this._terrainMode) terrain = null;
|
||||||
|
if (!id && !terrain) return;
|
||||||
|
this._retrying = true;
|
||||||
|
this._retryRequested = false;
|
||||||
|
// An attempt that outlives its deadline counts as a failure. Its signal is
|
||||||
|
// aborted, but the Esri and keyless factories ignore it and Cesium's
|
||||||
|
// requests have no timeout, so a late result is released, never used.
|
||||||
|
const attempt = async (create, release) => {
|
||||||
|
const expiry = new AbortController();
|
||||||
|
let expire;
|
||||||
|
const deadline = new Promise((resolve) => {
|
||||||
|
expire = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(expiry);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const timer = setTimeout(expire, FALLBACK_ATTEMPT_MS);
|
||||||
|
this._retryDeadlines.add(expire);
|
||||||
|
const pending = Promise.resolve().then(() =>
|
||||||
|
create?.({
|
||||||
|
signal: AbortSignal.any([this._abort.signal, expiry.signal]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([pending, deadline]);
|
||||||
|
if (result !== expiry) return result;
|
||||||
|
expiry.abort();
|
||||||
|
pending.then(release, () => {});
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null; // Still unavailable: keep the fallback.
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this._retryDeadlines.delete(expire);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Each recovers as soon as it loads, never waiting on the other.
|
||||||
|
const retryImagery = async () => {
|
||||||
|
const cached = this._imageryProviders.get(id);
|
||||||
|
const provider = await attempt(this._sources.get(id).imagery, (late) =>
|
||||||
|
this._dispose(late),
|
||||||
|
);
|
||||||
|
// Obsolete: destroyed, or the cache moved on while this was in flight.
|
||||||
|
if (this._destroyed || this._imageryProviders.get(id) !== cached) {
|
||||||
|
this._dispose(provider);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!provider) return false;
|
||||||
|
this._imageryProviders.set(id, Promise.resolve(resolved(provider, id)));
|
||||||
|
// Still showing the fallback once any switch settles: use the real one.
|
||||||
|
const recover = () => {
|
||||||
|
if (this._activeResolution === resolution)
|
||||||
|
void this.setStack(id, { [AUTOMATIC]: true });
|
||||||
|
};
|
||||||
|
if (!this._isSwitching) recover();
|
||||||
|
else {
|
||||||
|
const stop = this.subscribe(() => {
|
||||||
|
stop();
|
||||||
|
recover();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const retryTerrain = async () => {
|
||||||
|
const cached = this._terrainProviders.get(terrain.id);
|
||||||
|
const real = await attempt(terrain.create, (late) =>
|
||||||
|
this._dispose(late?.provider),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
this._destroyed ||
|
||||||
|
this._terrainProviders.get(terrain.id) !== cached ||
|
||||||
|
!real ||
|
||||||
|
real.fallback
|
||||||
|
) {
|
||||||
|
this._dispose(real?.provider);
|
||||||
|
return Boolean(real && !real.fallback);
|
||||||
|
}
|
||||||
|
this._terrainProviders.set(terrain.id, Promise.resolve(real));
|
||||||
|
if (this._terrainFallback === flat && this.viewer.scene.globe.show) {
|
||||||
|
this._applyTerrain(real);
|
||||||
|
this._dispose(flat.provider);
|
||||||
|
this._requestRender('map-stack');
|
||||||
|
} else {
|
||||||
|
this._terrainMode = null; // The next globe activation installs it.
|
||||||
|
// Release the replaced fallback once nothing shows it.
|
||||||
|
if (this._terrainFallback === flat) this._replacedTerrain = flat;
|
||||||
|
else this._dispose(flat.provider);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const settled = (retry) =>
|
||||||
|
retry().catch((error) => {
|
||||||
|
console.warn('[MapSourceController] fallback recovery failed:', error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const results = await Promise.all([
|
||||||
|
id && settled(retryImagery),
|
||||||
|
terrain && settled(retryTerrain),
|
||||||
|
]);
|
||||||
|
failed = results.includes(false);
|
||||||
|
} finally {
|
||||||
|
this._retrying = false;
|
||||||
|
}
|
||||||
|
if (this._destroyed) return;
|
||||||
|
this._retryDelay = failed
|
||||||
|
? Math.min(this._retryDelay * 2, FALLBACK_RETRY_MAX_MS)
|
||||||
|
: FALLBACK_RETRY_MS;
|
||||||
|
if (failed || this._retryRequested) this._scheduleRetry();
|
||||||
|
}
|
||||||
|
|
||||||
_cached(cache, id, create) {
|
_cached(cache, id, create) {
|
||||||
if (cache.has(id)) return cache.get(id);
|
if (cache.has(id)) return cache.get(id);
|
||||||
const promise = Promise.resolve()
|
const promise = Promise.resolve()
|
||||||
@@ -323,7 +495,7 @@ export class MapSourceController {
|
|||||||
try {
|
try {
|
||||||
const provider = await source.imagery({ signal: this._abort.signal });
|
const provider = await source.imagery({ signal: this._abort.signal });
|
||||||
if (this._destroyed) this._dispose(provider);
|
if (this._destroyed) this._dispose(provider);
|
||||||
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
|
return resolved(provider, stack.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fallback = source.constructionFallback;
|
const fallback = source.constructionFallback;
|
||||||
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
|
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
|
||||||
@@ -333,7 +505,11 @@ export class MapSourceController {
|
|||||||
this.getStack(fallback.id),
|
this.getStack(fallback.id),
|
||||||
next,
|
next,
|
||||||
);
|
);
|
||||||
return { ...resolution, fallbackMessage: fallback.message };
|
return {
|
||||||
|
...resolution,
|
||||||
|
fallbackMessage: fallback.message,
|
||||||
|
fallbackFor: stack.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -388,6 +564,7 @@ export class MapSourceController {
|
|||||||
this.viewer.imageryLayers.remove(this._imageryLayer, true);
|
this.viewer.imageryLayers.remove(this._imageryLayer, true);
|
||||||
this._imageryLayer = null;
|
this._imageryLayer = null;
|
||||||
this._activeImageryProvider = null;
|
this._activeImageryProvider = null;
|
||||||
|
this._activeResolution = null;
|
||||||
}
|
}
|
||||||
_dispose(value) {
|
_dispose(value) {
|
||||||
if (!value || this._disposed.has(value)) return;
|
if (!value || this._disposed.has(value)) return;
|
||||||
@@ -401,6 +578,12 @@ export class MapSourceController {
|
|||||||
this._subscribers.clear();
|
this._subscribers.clear();
|
||||||
this._switchGen++;
|
this._switchGen++;
|
||||||
this._abort.abort();
|
this._abort.abort();
|
||||||
|
clearTimeout(this._retryTimer);
|
||||||
|
for (const expire of this._retryDeadlines) expire();
|
||||||
|
this._retryDeadlines.clear();
|
||||||
|
this._retrying = false;
|
||||||
|
this._dispose(this._replacedTerrain?.provider);
|
||||||
|
this._replacedTerrain = null;
|
||||||
this._isSwitching = false;
|
this._isSwitching = false;
|
||||||
this._removeImageryLayer();
|
this._removeImageryLayer();
|
||||||
this._credits.destroy();
|
this._credits.destroy();
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const settle = async () => {
|
|||||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
};
|
};
|
||||||
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
|
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
|
||||||
function publicFixture() {
|
function publicFixture(options) {
|
||||||
const tileset = { show: true };
|
const tileset = { show: true };
|
||||||
const registry = createDefaultMapSources({ googleTileset: tileset });
|
const registry = createDefaultMapSources({ googleTileset: tileset });
|
||||||
const providers = new Map();
|
const providers = new Map();
|
||||||
@@ -84,7 +84,7 @@ function publicFixture() {
|
|||||||
create: async () => ({ provider: { id: 'terrain' } }),
|
create: async () => ({ provider: { id: 'terrain' } }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ...fixture(registry), registry, providers, tileset };
|
return { ...fixture(registry, options), registry, providers, tileset };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
|
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
|
||||||
@@ -839,3 +839,546 @@ test('each switch generation reports its origin: an outside setStack is manual,
|
|||||||
]);
|
]);
|
||||||
recovering.controller.destroy();
|
recovering.controller.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function failingEsri(env) {
|
||||||
|
const esri = { failing: true, attempts: 0, next: null };
|
||||||
|
env.registry.sources.find(
|
||||||
|
(source) => source.descriptor.id === 'esri-imagery',
|
||||||
|
).imagery = async () => {
|
||||||
|
esri.attempts++;
|
||||||
|
if (esri.next) await esri.next;
|
||||||
|
if (esri.failing) throw new Error('Esri unreachable');
|
||||||
|
return env.providers.get('esri-imagery');
|
||||||
|
};
|
||||||
|
return esri;
|
||||||
|
}
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((done) => (resolve = done));
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a failed Esri construction shows OSM at once, backs off while retries fail, and recovers in place', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
assert.equal(env.controller.getActiveId(), 'osm');
|
||||||
|
const osmLayer = env.imagery.at(-1);
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2, 'one background retry');
|
||||||
|
assert.equal(env.imagery.at(-1), osmLayer, 'a failed retry keeps the layer');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
assert.equal(esri.attempts, 2, 'a reselection does not retry at once');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2, 'the second retry waits twice as long');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 3);
|
||||||
|
esri.failing = false;
|
||||||
|
t.mock.timers.tick(120_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 4);
|
||||||
|
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||||
|
assert.equal(env.controller.getSwitchOrigin(), 'automatic');
|
||||||
|
assert.equal(env.controller.getState().lastError, null);
|
||||||
|
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
|
||||||
|
t.mock.timers.tick(600_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 4, 'the real provider ends the retries');
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries of a fallback that keeps failing back off to a 10 minute cap', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
for (const delay of [30, 60, 120, 240, 480, 600, 600]) {
|
||||||
|
t.mock.timers.tick(delay * 1000);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
assert.equal(esri.attempts, 8);
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Esri is retried only while its fallback is shown', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 1, 'no retry while OSM was chosen');
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
assert.equal(esri.attempts, 1, 'the cached fallback shows at once');
|
||||||
|
assert.equal(env.controller.getActiveId(), 'osm');
|
||||||
|
esri.failing = false;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2);
|
||||||
|
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an Esri recovery that lands mid-switch applies once the switch settles', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
const retry = deferred();
|
||||||
|
esri.next = retry.promise;
|
||||||
|
esri.failing = false;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
// The fallback activates OSM's terrain; make that switch slow.
|
||||||
|
const slow = deferred();
|
||||||
|
env.registry.sources.find(
|
||||||
|
(source) => source.descriptor.id === 'osm',
|
||||||
|
).terrain = { id: 'slow', create: () => slow.promise };
|
||||||
|
const switching = env.controller.setStack('esri-imagery');
|
||||||
|
retry.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(env.controller.getActiveId(), 'osm', 'the switch is still open');
|
||||||
|
slow.resolve({ provider: { id: 'slow' } });
|
||||||
|
await switching;
|
||||||
|
await settle();
|
||||||
|
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||||
|
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function terrainFixture(t, create) {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
let renders = 0;
|
||||||
|
const env = publicFixture({ requestRender: () => renders++ });
|
||||||
|
const installed = [];
|
||||||
|
Object.defineProperty(env.viewer, 'terrainProvider', {
|
||||||
|
get: () => installed.at(-1),
|
||||||
|
set: (provider) => installed.push(provider),
|
||||||
|
});
|
||||||
|
const terrain = { id: 'keyless', calls: 0 };
|
||||||
|
terrain.create = () => create(++terrain.calls);
|
||||||
|
for (const source of env.registry.sources)
|
||||||
|
if (source.imagery) source.terrain = terrain;
|
||||||
|
return { env, installed, terrain, renders: () => renders };
|
||||||
|
}
|
||||||
|
const flatTerrain = (n) => ({ provider: { id: `flat-${n}` }, fallback: true });
|
||||||
|
|
||||||
|
test('flat fallback terrain stays installed while retries run, and real terrain replaces it live', async (t) => {
|
||||||
|
const real = { id: 'real' };
|
||||||
|
let working = false;
|
||||||
|
const { env, installed, terrain, renders } = terrainFixture(t, async (n) =>
|
||||||
|
working ? { provider: real } : flatTerrain(n),
|
||||||
|
);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
const [flat] = installed;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(terrain.calls, 2, 'one background retry');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
assert.deepEqual(installed, [flat], 'failed retries and switches keep it');
|
||||||
|
working = true;
|
||||||
|
const before = renders();
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(terrain.calls, 3);
|
||||||
|
assert.deepEqual(installed, [flat, real]);
|
||||||
|
assert.equal(renders(), before + 1, 'the live swap requests a render');
|
||||||
|
t.mock.timers.tick(600_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(terrain.calls, 3, 'the real terrain ends the retries');
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terrain is not retried or installed while a 3D tileset hides the globe', async (t) => {
|
||||||
|
const real = { id: 'real' };
|
||||||
|
let working = false;
|
||||||
|
const { env, installed, terrain } = terrainFixture(t, async (n) =>
|
||||||
|
working ? { provider: real } : flatTerrain(n),
|
||||||
|
);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
await env.controller.setStack('photoreal');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(terrain.calls, 1, 'no retry behind a hidden globe');
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
const hidden = deferred();
|
||||||
|
terrain.create = async () => {
|
||||||
|
terrain.calls++;
|
||||||
|
await hidden.promise;
|
||||||
|
return { provider: real };
|
||||||
|
};
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(terrain.calls, 2, 'the globe activation re-arms the retry');
|
||||||
|
await env.controller.setStack('photoreal');
|
||||||
|
hidden.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(installed.length, 1, 'nothing is installed behind a tileset');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
assert.equal(installed.at(-1), real, 'the next globe activation installs it');
|
||||||
|
assert.equal(terrain.calls, 2);
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a switch that read the flat fallback installs the terrain a retry just cached', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
// Sweep the microtask distance between the retry landing and the switch.
|
||||||
|
for (let hops = 0; hops < 12; hops++) {
|
||||||
|
const real = { id: 'real' },
|
||||||
|
gate = deferred();
|
||||||
|
const keyless = {
|
||||||
|
id: 'keyless',
|
||||||
|
create: async () => {
|
||||||
|
if (!keyless.retried) return flatTerrain(1);
|
||||||
|
await gate.promise;
|
||||||
|
return { provider: real };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const other = { id: 'other', create: async () => ({ provider: {} }) };
|
||||||
|
const source = (id, terrain) => ({
|
||||||
|
descriptor: descriptor(id),
|
||||||
|
imagery: async () => ({ id }),
|
||||||
|
terrain,
|
||||||
|
});
|
||||||
|
const env = fixture({
|
||||||
|
defaultId: 'a',
|
||||||
|
sources: [source('a', keyless), source('c', other)],
|
||||||
|
});
|
||||||
|
const installed = [];
|
||||||
|
Object.defineProperty(env.viewer, 'terrainProvider', {
|
||||||
|
get: () => installed.at(-1),
|
||||||
|
set: (provider) => installed.push(provider),
|
||||||
|
});
|
||||||
|
await env.controller.setStack('a');
|
||||||
|
keyless.retried = true;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
await env.controller.setStack('c');
|
||||||
|
gate.resolve();
|
||||||
|
for (let i = 0; i < hops; i++) await null;
|
||||||
|
await env.controller.setStack('a');
|
||||||
|
await settle();
|
||||||
|
assert.equal(installed.at(-1), real, `hops ${hops}`);
|
||||||
|
env.controller.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy cancels a pending retry and releases one in flight', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
env.controller.destroy();
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 1, 'a pending retry is cancelled');
|
||||||
|
|
||||||
|
const late = publicFixture();
|
||||||
|
const lateEsri = failingEsri(late);
|
||||||
|
await late.controller.setStack('esri-imagery');
|
||||||
|
const retry = deferred();
|
||||||
|
lateEsri.next = retry.promise;
|
||||||
|
lateEsri.failing = false;
|
||||||
|
let destroyed = 0;
|
||||||
|
late.providers.get('esri-imagery').destroy = () => destroyed++;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
late.controller.destroy();
|
||||||
|
retry.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(destroyed, 1, 'a late provider is released');
|
||||||
|
assert.equal(late.imagery.length, 0);
|
||||||
|
t.mock.timers.tick(600_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(lateEsri.attempts, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries stay single-flight when an activation re-shows the fallback mid-retry', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const created = [],
|
||||||
|
pending = [];
|
||||||
|
let attempts = 0;
|
||||||
|
env.registry.sources.find(
|
||||||
|
(source) => source.descriptor.id === 'esri-imagery',
|
||||||
|
).imagery = async () => {
|
||||||
|
if (++attempts === 1) throw new Error('Esri unreachable');
|
||||||
|
const gate = deferred();
|
||||||
|
pending.push(gate);
|
||||||
|
await gate.promise;
|
||||||
|
const provider = { id: `esri-${attempts}`, errorEvent: event() };
|
||||||
|
provider.destroy = () => (provider.destroyed = true);
|
||||||
|
created.push(provider);
|
||||||
|
return provider;
|
||||||
|
};
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(attempts, 2, 'retry #1 is in flight');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
assert.equal(env.controller.getActiveId(), 'osm', 'the fallback is shown');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(attempts, 2, 'no second retry while one is in flight');
|
||||||
|
for (const gate of pending.reverse()) {
|
||||||
|
gate.resolve();
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
const shown = env.imagery.at(-1).provider;
|
||||||
|
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
assert.equal(env.imagery.at(-1).provider, shown, 'the cached one is shown');
|
||||||
|
env.controller.destroy();
|
||||||
|
await settle();
|
||||||
|
assert.ok(created.length > 0);
|
||||||
|
for (const provider of created)
|
||||||
|
assert.equal(provider.destroyed, true, `${provider.id} is disposed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stalled terrain retry does not hold back recovered imagery', async (t) => {
|
||||||
|
const stalled = deferred();
|
||||||
|
const { env, terrain } = terrainFixture(t, async (n) => {
|
||||||
|
if (n > 1) await stalled.promise;
|
||||||
|
return flatTerrain(n);
|
||||||
|
});
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
assert.equal(env.controller.getActiveId(), 'osm');
|
||||||
|
esri.failing = false;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(terrain.calls, 2, 'the terrain retry is in flight');
|
||||||
|
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||||
|
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
|
||||||
|
stalled.resolve();
|
||||||
|
await settle();
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stalled imagery retry does not hold back recovered terrain', async (t) => {
|
||||||
|
const real = { id: 'real' };
|
||||||
|
const { env, installed } = terrainFixture(t, async (n) =>
|
||||||
|
n > 1 ? { provider: real } : flatTerrain(n),
|
||||||
|
);
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
const stalled = deferred();
|
||||||
|
esri.next = stalled.promise;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2, 'the imagery retry is in flight');
|
||||||
|
assert.equal(installed.at(-1), real, 'real terrain is installed');
|
||||||
|
stalled.resolve();
|
||||||
|
await settle();
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a retry attempt that never settles is abandoned at its deadline and released if it lands late', async (t) => {
|
||||||
|
const hung = deferred();
|
||||||
|
const late = { id: 'late', destroy: () => (late.destroyed = true) };
|
||||||
|
const { env, installed, terrain } = terrainFixture(t, async (n) => {
|
||||||
|
if (n === 2) await hung.promise;
|
||||||
|
return n === 2 ? { provider: late } : flatTerrain(n);
|
||||||
|
});
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2, 'imagery failed');
|
||||||
|
assert.equal(terrain.calls, 2, 'terrain hangs');
|
||||||
|
t.mock.timers.tick(60_000); // The hung attempt's deadline.
|
||||||
|
await settle();
|
||||||
|
t.mock.timers.tick(60_000); // The doubled backoff.
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 3, 'imagery is retried again');
|
||||||
|
assert.equal(terrain.calls, 3, 'terrain is retried again');
|
||||||
|
hung.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(late.destroyed, true, 'the late provider is released');
|
||||||
|
assert.ok(!installed.includes(late), 'and never installed');
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed retry after a mid-retry re-show re-arms once, after the doubled delay', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
const gate = deferred();
|
||||||
|
esri.next = gate.promise;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
gate.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2);
|
||||||
|
t.mock.timers.tick(59_999);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2, 'not before the doubled delay');
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 3, 'one retry after it');
|
||||||
|
t.mock.timers.tick(119_999);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 3, 'no second timer was armed');
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy releases recovered imagery and terrain that lands afterwards', async (t) => {
|
||||||
|
const stalled = deferred();
|
||||||
|
const real = { id: 'real', destroy: () => (real.destroyed = true) };
|
||||||
|
const { env, installed } = terrainFixture(t, async (n) => {
|
||||||
|
if (n === 1) return flatTerrain(n);
|
||||||
|
await stalled.promise;
|
||||||
|
return { provider: real };
|
||||||
|
});
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
const imagery = env.providers.get('esri-imagery');
|
||||||
|
imagery.destroy = () => (imagery.destroyed = true);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
esri.failing = false;
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(env.imagery.at(-1).provider, imagery, 'imagery has landed');
|
||||||
|
env.controller.destroy();
|
||||||
|
stalled.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(imagery.destroyed, true);
|
||||||
|
assert.equal(real.destroyed, true);
|
||||||
|
assert.ok(!installed.includes(real));
|
||||||
|
});
|
||||||
|
|
||||||
|
function liveTimers(t) {
|
||||||
|
const live = new Set();
|
||||||
|
const { setTimeout: set, clearTimeout: clear } = globalThis;
|
||||||
|
globalThis.setTimeout = (fn, ms, ...args) => {
|
||||||
|
const id = set(
|
||||||
|
(...values) => {
|
||||||
|
live.delete(id);
|
||||||
|
fn(...values);
|
||||||
|
},
|
||||||
|
ms,
|
||||||
|
...args,
|
||||||
|
);
|
||||||
|
live.add(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
globalThis.clearTimeout = (id) => {
|
||||||
|
live.delete(id);
|
||||||
|
clear(id);
|
||||||
|
};
|
||||||
|
t.after(() =>
|
||||||
|
Object.assign(globalThis, { setTimeout: set, clearTimeout: clear }),
|
||||||
|
);
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('destroy during a hung retry attempt clears its deadline at once', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const live = liveTimers(t);
|
||||||
|
const env = publicFixture();
|
||||||
|
const esri = failingEsri(env);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
esri.next = new Promise(() => {});
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(esri.attempts, 2, 'the attempt hangs');
|
||||||
|
assert.equal(live.size, 1, 'only its deadline is armed');
|
||||||
|
env.controller.destroy();
|
||||||
|
assert.equal(live.size, 0, 'destroy clears the deadline');
|
||||||
|
assert.equal(env.controller._retrying, false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(live.size, 0, 'and nothing re-arms');
|
||||||
|
});
|
||||||
|
|
||||||
|
function destroyableFlat(n) {
|
||||||
|
const provider = { id: `flat-${n}` };
|
||||||
|
provider.destroy = () => (provider.destroyed = true);
|
||||||
|
return { provider, fallback: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('terrain recovered on screen releases the fallback it replaced', async (t) => {
|
||||||
|
const real = { id: 'real' };
|
||||||
|
let flat;
|
||||||
|
const { env, installed } = terrainFixture(t, async (n) =>
|
||||||
|
n === 1 ? (flat = destroyableFlat(n)) : { provider: real },
|
||||||
|
);
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(installed.at(-1), real);
|
||||||
|
assert.equal(flat.provider.destroyed, true);
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terrain recovered behind a tileset releases the fallback once a globe replaces it', async (t) => {
|
||||||
|
const real = { id: 'real' };
|
||||||
|
const gate = deferred();
|
||||||
|
let flat;
|
||||||
|
const { env, installed } = terrainFixture(t, async (n) => {
|
||||||
|
if (n === 1) return (flat = destroyableFlat(n));
|
||||||
|
await gate.promise;
|
||||||
|
return { provider: real };
|
||||||
|
});
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
await env.controller.setStack('photoreal');
|
||||||
|
gate.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(installed.at(-1), flat.provider);
|
||||||
|
assert.ok(!flat.provider.destroyed, 'still installed behind the tileset');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
assert.equal(installed.at(-1), real);
|
||||||
|
assert.equal(flat.provider.destroyed, true);
|
||||||
|
env.controller.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hung imagery attempt that lands after its deadline is released, never cached or shown', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const env = publicFixture();
|
||||||
|
const hung = deferred();
|
||||||
|
const late = { id: 'late', errorEvent: event() };
|
||||||
|
const good = { id: 'good', errorEvent: event() };
|
||||||
|
for (const provider of [late, good])
|
||||||
|
provider.destroy = () => (provider.destroyed = true);
|
||||||
|
let attempts = 0;
|
||||||
|
env.registry.sources.find(
|
||||||
|
(source) => source.descriptor.id === 'esri-imagery',
|
||||||
|
).imagery = async () => {
|
||||||
|
if (++attempts === 1) throw new Error('Esri unreachable');
|
||||||
|
if (attempts === 2) {
|
||||||
|
await hung.promise;
|
||||||
|
return late;
|
||||||
|
}
|
||||||
|
return good;
|
||||||
|
};
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
t.mock.timers.tick(30_000);
|
||||||
|
await settle();
|
||||||
|
t.mock.timers.tick(60_000); // The deadline.
|
||||||
|
await settle();
|
||||||
|
hung.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.equal(late.destroyed, true, 'the late provider is released');
|
||||||
|
assert.equal(env.controller.getActiveId(), 'osm', 'and not shown');
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
await settle();
|
||||||
|
assert.equal(attempts, 3);
|
||||||
|
assert.equal(env.imagery.at(-1).provider, good, 'the next retry installs');
|
||||||
|
await env.controller.setStack('osm');
|
||||||
|
await env.controller.setStack('esri-imagery');
|
||||||
|
assert.equal(env.imagery.at(-1).provider, good, 'only its own is cached');
|
||||||
|
env.controller.destroy();
|
||||||
|
await settle();
|
||||||
|
assert.equal(good.destroyed, true);
|
||||||
|
});
|
||||||
@@ -317,3 +317,13 @@ test('both Google 3D routes keep tiles drawing while draped imagery loads', asyn
|
|||||||
assert.equal(calls[1][1].asynchronouslyLoadImagery, true);
|
assert.equal(calls[1][1].asynchronouslyLoadImagery, true);
|
||||||
assert.equal(calls[1][1].enableCollision, true);
|
assert.equal(calls[1][1].enableCollision, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('unreachable keyless terrain falls back to flat terrain marked as a fallback', async (t) => {
|
||||||
|
t.mock.method(Cesium.CesiumTerrainProvider, 'fromUrl', async () => {
|
||||||
|
throw new Error('layer.json unreachable');
|
||||||
|
});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
const result = await createKeylessTerrain();
|
||||||
|
assert.ok(result.provider instanceof Cesium.EllipsoidTerrainProvider);
|
||||||
|
assert.equal(result.fallback, true);
|
||||||
|
});
|
||||||
+1
-1
@@ -59,6 +59,6 @@ export async function createKeylessTerrain() {
|
|||||||
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
|
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
return { provider: new Cesium.EllipsoidTerrainProvider() };
|
return { provider: new Cesium.EllipsoidTerrainProvider(), fallback: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Answer a decimal-degree query without asking anyone.
|
* Answer a decimal-degree or geo-URI query without asking anyone.
|
||||||
*
|
*
|
||||||
* It sits ahead of the network geocoders, so a coordinate costs no request and
|
* It sits ahead of the network geocoders, so a coordinate costs no request and
|
||||||
* works with no key. Anything that is not exactly a coordinate is passed on
|
* works with no key. Anything that is not exactly a coordinate is passed on
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* as 12, 34 flies the operator somewhere they did not ask for and looks like a
|
* as 12, 34 flies the operator somewhere they did not ask for and looks like a
|
||||||
* successful search. So every rule here is exact — a component is a decimal
|
* successful search. So every rule here is exact — a component is a decimal
|
||||||
* number, optionally signed, optionally marked with one hemisphere letter, and
|
* number, optionally signed, optionally marked with one hemisphere letter, and
|
||||||
* nothing else. Degrees/minutes/seconds and grid references are not handled;
|
* nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar.
|
||||||
|
* Degrees/minutes/seconds and grid references are not handled;
|
||||||
* they fall through to the geocoders, which is what happens today.
|
* they fall through to the geocoders, which is what happens today.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,6 +14,10 @@
|
|||||||
const COMPONENT =
|
const COMPONENT =
|
||||||
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
|
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
|
||||||
|
|
||||||
|
/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */
|
||||||
|
const GEO_URI =
|
||||||
|
/^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i;
|
||||||
|
|
||||||
/** Hemisphere letters, by the axis each one can name. */
|
/** Hemisphere letters, by the axis each one can name. */
|
||||||
const LATITUDE_LETTERS = new Set(['N', 'S']);
|
const LATITUDE_LETTERS = new Set(['N', 'S']);
|
||||||
|
|
||||||
@@ -61,20 +66,25 @@ export function formatCoordinateLabel(lat, lon) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a decimal-degree query, or return null.
|
* Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null.
|
||||||
*
|
*
|
||||||
* Both orders are accepted when the hemisphere letters say which is which
|
* Both orders are accepted when the hemisphere letters say which is which
|
||||||
* (`W 40, N 74` is longitude then latitude); with no letters the conventional
|
* (`W 40, N 74` is longitude then latitude); with no letters the conventional
|
||||||
* latitude-then-longitude order applies. Two letters naming the same axis, a
|
* latitude-then-longitude order applies. Two letters naming the same axis, a
|
||||||
* letter that fixes nothing, an out-of-range latitude or longitude, and any
|
* letter that fixes nothing, an out-of-range latitude or longitude, and any
|
||||||
* trailing text all decline.
|
* trailing text all decline.
|
||||||
|
* Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`;
|
||||||
|
* altitude, uncertainty, other parameters and query/fragment suffixes decline.
|
||||||
*
|
*
|
||||||
* @param {string} query
|
* @param {string} query
|
||||||
* @returns {{ lat: number, lon: number, label: string } | null}
|
* @returns {{ lat: number, lon: number, label: string } | null}
|
||||||
*/
|
*/
|
||||||
export function parseCoordinateQuery(query) {
|
export function parseCoordinateQuery(query) {
|
||||||
if (typeof query !== 'string') return null;
|
if (typeof query !== 'string') return null;
|
||||||
const parts = splitComponents(query);
|
const text = query.trim();
|
||||||
|
const parts = /^geo:/i.test(text)
|
||||||
|
? GEO_URI.exec(text)?.slice(1, 3)
|
||||||
|
: splitComponents(text);
|
||||||
if (!parts) return null;
|
if (!parts) return null;
|
||||||
|
|
||||||
const first = readComponent(parts[0]);
|
const first = readComponent(parts[0]);
|
||||||
|
|||||||
@@ -132,3 +132,67 @@ test('the poles and the dateline are inside the accepted range', () => {
|
|||||||
assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90);
|
assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90);
|
||||||
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180);
|
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => {
|
||||||
|
for (const query of [
|
||||||
|
'geo:39.7392,-104.9903',
|
||||||
|
'geo:39.7392,-104.9903;crs=wgs84',
|
||||||
|
' GEO:39.7392,-104.9903;CRS=WGS84 ',
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(parseCoordinateQuery(query), {
|
||||||
|
lat: 39.7392,
|
||||||
|
lon: -104.9903,
|
||||||
|
label: '39.7392° N, 104.9903° W',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [query, decimal] of [
|
||||||
|
['geo:0,0', '0,0'],
|
||||||
|
['geo:-33.8568,151.2153', '-33.8568,151.2153'],
|
||||||
|
['geo:90,180', '90,180'],
|
||||||
|
['geo:-90,-180;crs=wgs84', '-90,-180'],
|
||||||
|
['geo:09,007', '9,7'],
|
||||||
|
['geo:-0,-0', '-0,-0'],
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseCoordinateQuery(query),
|
||||||
|
parseCoordinateQuery(decimal),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed or unsupported geo URIs are refused without losing their meaning', () => {
|
||||||
|
for (const query of [
|
||||||
|
'geo:',
|
||||||
|
'geo:39.7392',
|
||||||
|
'geo:,104.9903',
|
||||||
|
'geo:39.7392,,104.9903',
|
||||||
|
'geo:39.7392,-104.9903,',
|
||||||
|
'geo:91,0',
|
||||||
|
'geo:-90.1,0',
|
||||||
|
'geo:0,181',
|
||||||
|
'geo:0,-180.1',
|
||||||
|
'geo:000,0',
|
||||||
|
'geo:0,0000',
|
||||||
|
'geo:+39.7392,-104.9903',
|
||||||
|
'geo:.5,-.25',
|
||||||
|
'geo:1e1,2',
|
||||||
|
'geo:NaN,0',
|
||||||
|
'geo:Infinity,0',
|
||||||
|
'geo:39.7392, -104.9903',
|
||||||
|
'geo:39.7392;-104.9903',
|
||||||
|
'geo:39.7392N,104.9903W',
|
||||||
|
'geo:39.7392,-104.9903,1600',
|
||||||
|
'geo:39.7392,-104.9903;u=10',
|
||||||
|
'geo:39.7392,-104.9903;crs=unknown',
|
||||||
|
'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84',
|
||||||
|
'geo:39.7392,-104.9903;crs=wgs84;u=0',
|
||||||
|
'geo:39.7392,-104.9903;custom=value',
|
||||||
|
'geo:0,0?q=39.7392,-104.9903(Denver)',
|
||||||
|
'geo:39.7392,-104.9903#Denver',
|
||||||
|
'geo:39.7392,-104.9903 trailing text',
|
||||||
|
'geo://39.7392,-104.9903',
|
||||||
|
'https://example.com/geo:39.7392,-104.9903',
|
||||||
|
]) {
|
||||||
|
assert.equal(parseCoordinateQuery(query), null, query);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
||||||
import { createPresetGeocoder } from './presetGeocoder.js';
|
import { createPresetGeocoder } from './presetGeocoder.js';
|
||||||
import { createPlaceSearch } from './placeSearch.js';
|
import { createPlaceSearch } from './placeSearch.js';
|
||||||
|
import { createDefaultPlaceSearch } from './defaults.js';
|
||||||
|
|
||||||
const PRESETS = {
|
const PRESETS = {
|
||||||
austin: {
|
austin: {
|
||||||
@@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('default search resolves a geo URI exactly without requesting a geocoder', async () => {
|
||||||
|
const requests = [];
|
||||||
|
const search = createDefaultPlaceSearch({
|
||||||
|
resolveApiKey: () => 'test-key',
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
requests.push(url);
|
||||||
|
throw new Error('No network request should be needed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { place, fallbackUsed } = await search.geocode(
|
||||||
|
'geo:39.7392,-104.9903;crs=wgs84',
|
||||||
|
);
|
||||||
|
assert.deepEqual(requests, []);
|
||||||
|
assert.equal(fallbackUsed, false);
|
||||||
|
assert.equal(place.lat, 39.7392);
|
||||||
|
assert.equal(place.lng, -104.9903);
|
||||||
|
assert.equal(place.exact, true);
|
||||||
|
assert.deepEqual(place.types, ['coordinate']);
|
||||||
|
assert.ok(place.viewport.southwest.lat < place.lat);
|
||||||
|
assert.ok(place.viewport.northeast.lat > place.lat);
|
||||||
|
assert.ok(place.viewport.southwest.lng < place.lng);
|
||||||
|
assert.ok(place.viewport.northeast.lng > place.lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsupported geo URIs reach the next search provider unchanged', async () => {
|
||||||
|
const queries = [
|
||||||
|
'geo:39.7392,-104.9903,1600',
|
||||||
|
'geo:39.7392,-104.9903;u=10',
|
||||||
|
'geo:39.7392,-104.9903;crs=unknown',
|
||||||
|
'geo:0,0?q=Denver',
|
||||||
|
];
|
||||||
|
const received = [];
|
||||||
|
const search = createPlaceSearch({
|
||||||
|
providers: [
|
||||||
|
createCoordinateGeocoder(),
|
||||||
|
{
|
||||||
|
async geocode(query) {
|
||||||
|
received.push(query);
|
||||||
|
return { place: null, answered: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (const query of queries) {
|
||||||
|
assert.equal((await search.geocode(query)).place, null);
|
||||||
|
}
|
||||||
|
assert.deepEqual(received, queries);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user