mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(layers): Recent Imagery — open satellite imagery for a selected box (#716)
Select a box on the world and browse the last 30 days of open satellite imagery over it, keyless: NASA GIBS Harmonized Landsat Sentinel-2 (30 m) and the VIIRS daily overview (250 m), with a day strip, IMAGE / VS BASEMAP / A / B modes with a swipe divider, PNG export and share links. The before/after swipe from the Nepal scene moves into shared modules so both use it. Co-authored-by: manjunath22466 <[email protected]>
This commit is contained in:
1 parent
25491f7e33
commit
082074a006
50 files changed
+13169
-440
No files matched your search
+75
-3
@@ -1,6 +1,13 @@
|
||||
import { indexMapSources } from './registry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMapCredits } from './credits.js';
|
||||
import { acquireImageryComparison } from './imageryComparison.js';
|
||||
|
||||
/**
|
||||
* Private `setStack` option marking a switch the controller issues itself
|
||||
* (a tile-failure fallback); outside callers cannot forge it.
|
||||
*/
|
||||
const AUTOMATIC = Symbol('automatic switch');
|
||||
|
||||
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
|
||||
export class MapSourceController {
|
||||
@@ -33,12 +40,15 @@ export class MapSourceController {
|
||||
this._ownedTilesets = new Set();
|
||||
this._disposed = new WeakSet();
|
||||
this._switchGen = 0;
|
||||
/** Who chose the current generation's stack: 'manual' | 'automatic'. */
|
||||
this._switchOrigin = 'manual';
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._terrainMode = null;
|
||||
this._subscribers = new Set();
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
@@ -84,6 +94,16 @@ export class MapSourceController {
|
||||
getSwitchGeneration() {
|
||||
return this._switchGen;
|
||||
}
|
||||
/**
|
||||
* Who chose the stack of the current switch generation: 'manual' for a
|
||||
* `setStack` issued from outside (the operator, a scene, a lease),
|
||||
* 'automatic' when the controller replaced it itself (a construction or
|
||||
* tile-failure fallback, or a recovery after a failed activation).
|
||||
* @returns {'manual' | 'automatic'}
|
||||
*/
|
||||
getSwitchOrigin() {
|
||||
return this._switchOrigin;
|
||||
}
|
||||
getState(status = this._isSwitching ? 'switching' : 'ready') {
|
||||
return {
|
||||
activeId: this._activeId,
|
||||
@@ -91,6 +111,7 @@ export class MapSourceController {
|
||||
stacks: this.getStacks(),
|
||||
status,
|
||||
lastError: this._lastError,
|
||||
switchOrigin: this._switchOrigin,
|
||||
...this._registry.state,
|
||||
};
|
||||
}
|
||||
@@ -98,7 +119,48 @@ export class MapSourceController {
|
||||
this._onChange?.(this.getState(status));
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false } = {}) {
|
||||
/**
|
||||
* Hear every settled activation: a stack switch, a silent switch, a
|
||||
* fallback after a failure, a recovery. Unlike `onChange` (the UI's
|
||||
* status feed, muted by `silent`), listeners fire once per `setStack`
|
||||
* after `_activeId` has settled, so a layer that drapes on the active
|
||||
* host can rebind. The state's `switchOrigin` says whether the caller
|
||||
* ('manual') or the controller itself ('automatic') chose the settled
|
||||
* stack. A listener that throws is logged, never fatal.
|
||||
* @param {(state: object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
this._subscribers.add(listener);
|
||||
return () => {
|
||||
this._subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
_notifySubscribers() {
|
||||
if (this._destroyed || !this._subscribers.size) return;
|
||||
const state = this.getState();
|
||||
for (const listener of [...this._subscribers]) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch (error) {
|
||||
console.warn('[MapSourceController] subscriber failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lease the map for an imagery comparison. switchPolicy: 'preserve' (never
|
||||
* switch) | 'esri' (switch to Esri imagery from any other stack) |
|
||||
* 'esri-if-photoreal' (switch only when Google 3D is active). Throws while
|
||||
* another owner holds the lease; see acquireImageryComparison().
|
||||
*/
|
||||
acquireImageryComparison(options) {
|
||||
return acquireImageryComparison(this, options);
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false, [AUTOMATIC]: automatic = false } = {}) {
|
||||
if (this._destroyed) return this.getState();
|
||||
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
|
||||
if (!stack) return null;
|
||||
@@ -109,6 +171,7 @@ export class MapSourceController {
|
||||
return this.getState();
|
||||
}
|
||||
const gen = ++this._switchGen;
|
||||
this._switchOrigin = automatic ? 'automatic' : 'manual';
|
||||
this._isSwitching = true;
|
||||
this._lastError = null;
|
||||
if (!silent) this._emitChange('switching');
|
||||
@@ -116,6 +179,7 @@ export class MapSourceController {
|
||||
const activation = await this._activate(stack, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (this._activeId !== stack.id) this._switchOrigin = 'automatic';
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
@@ -133,6 +197,8 @@ export class MapSourceController {
|
||||
recovery.id !== stack.id &&
|
||||
this.isStackAvailable(recovery.id)
|
||||
) {
|
||||
// Whatever settles now is the controller's choice, not the caller's.
|
||||
this._switchOrigin = 'automatic';
|
||||
try {
|
||||
const activation = await this._activate(recovery, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
@@ -145,7 +211,12 @@ export class MapSourceController {
|
||||
}
|
||||
if (!silent) this._emitChange('error');
|
||||
} finally {
|
||||
if (gen === this._switchGen) this._isSwitching = false;
|
||||
if (gen === this._switchGen) {
|
||||
this._isSwitching = false;
|
||||
// The active id has settled for this generation (activated, fell
|
||||
// back, or recovered): every subscriber hears it, silent or not.
|
||||
this._notifySubscribers();
|
||||
}
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
@@ -290,7 +361,7 @@ export class MapSourceController {
|
||||
this.getStack(resolution.effectiveStackId),
|
||||
);
|
||||
const expectedGen = this._switchGen + 1;
|
||||
void this.setStack(fallback.id, { silent: true })
|
||||
void this.setStack(fallback.id, { silent: true, [AUTOMATIC]: true })
|
||||
.then((state) => {
|
||||
if (
|
||||
!this._destroyed &&
|
||||
@@ -324,6 +395,7 @@ export class MapSourceController {
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._subscribers.clear();
|
||||
this._switchGen++;
|
||||
this._abort.abort();
|
||||
this._isSwitching = false;
|
||||
|
||||
@@ -458,3 +458,384 @@ test('imagery host tileset follows supplied, owned and globe map sources', async
|
||||
controller.destroy();
|
||||
assert.equal(controller.getImageryHostTileset(), null);
|
||||
});
|
||||
|
||||
function leaseFixture() {
|
||||
const env = publicFixture();
|
||||
const switches = [];
|
||||
const setStack = env.controller.setStack.bind(env.controller);
|
||||
env.controller.setStack = (id, options) => {
|
||||
switches.push(id);
|
||||
return setStack(id, options);
|
||||
};
|
||||
return { ...env, switches };
|
||||
}
|
||||
|
||||
test('an imagery comparison lease rejects a second owner synchronously until released', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'preserve',
|
||||
});
|
||||
assert.throws(
|
||||
() => env.controller.acquireImageryComparison({ owner: 'recent' }),
|
||||
/held by nepal/,
|
||||
);
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await lease.release();
|
||||
const next = env.controller.acquireImageryComparison({ owner: 'recent' });
|
||||
assert.deepEqual(await next.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await next.release();
|
||||
assert.deepEqual(env.switches, []);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test("the 'esri' policy switches from any other stack and release restores it", async () => {
|
||||
for (const initial of ['photoreal', 'osm']) {
|
||||
const env = leaseFixture();
|
||||
if (initial !== env.controller.getActiveId())
|
||||
await env.controller.setStack(initial);
|
||||
env.switches.length = 0;
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'esri-imagery',
|
||||
});
|
||||
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), initial);
|
||||
assert.deepEqual(env.switches, ['esri-imagery', initial]);
|
||||
env.controller.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("the 'esri-if-photoreal' policy leaves any 2D basemap alone", async () => {
|
||||
const photoreal = leaseFixture();
|
||||
const fromPhotoreal = photoreal.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri-if-photoreal',
|
||||
});
|
||||
assert.equal((await fromPhotoreal.ready).activeId, 'esri-imagery');
|
||||
await fromPhotoreal.release();
|
||||
assert.deepEqual(photoreal.switches, ['esri-imagery', 'photoreal']);
|
||||
photoreal.controller.destroy();
|
||||
|
||||
const osm = leaseFixture();
|
||||
await osm.controller.setStack('osm');
|
||||
osm.switches.length = 0;
|
||||
const fromOsm = osm.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri-if-photoreal',
|
||||
});
|
||||
assert.deepEqual(await fromOsm.ready, { status: 'ready', activeId: 'osm' });
|
||||
await fromOsm.release();
|
||||
assert.equal(osm.controller.getActiveId(), 'osm');
|
||||
assert.deepEqual(osm.switches, []);
|
||||
osm.controller.destroy();
|
||||
});
|
||||
|
||||
test("the 'preserve' policy never switches, whatever is active", async () => {
|
||||
for (const initial of ['photoreal', 'esri-imagery', 'osm']) {
|
||||
const env = leaseFixture();
|
||||
if (initial !== env.controller.getActiveId())
|
||||
await env.controller.setStack(initial);
|
||||
env.switches.length = 0;
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
});
|
||||
assert.deepEqual(await lease.ready, { status: 'ready', activeId: initial });
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), initial);
|
||||
assert.deepEqual(env.switches, []);
|
||||
env.controller.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('release keeps an operator choice made while the comparison was open', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await lease.ready;
|
||||
await env.controller.setStack('osm');
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'osm']);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('release keeps a fallback that replaced the comparison stack', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await lease.ready;
|
||||
const errors = env.providers.get('esri-imagery').errorEvent;
|
||||
errors.raise();
|
||||
errors.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
await lease.release();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'osm']);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('release during acquisition lets no late activation survive', async () => {
|
||||
const env = leaseFixture();
|
||||
let resolve;
|
||||
env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'esri-imagery',
|
||||
).imagery = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await settle();
|
||||
const releasing = lease.release();
|
||||
resolve(env.providers.get('esri-imagery'));
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'superseded',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await releasing;
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
assert.equal(env.viewer.scene.globe.show, false);
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.equal(env.tileset.show, true);
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'photoreal']);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('release is idempotent and frees the lease for the next owner', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
await lease.ready;
|
||||
const first = lease.release();
|
||||
assert.equal(lease.release(), first);
|
||||
await first;
|
||||
await lease.release();
|
||||
assert.deepEqual(env.switches, ['esri-imagery', 'photoreal']);
|
||||
const next = env.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.equal((await next.ready).status, 'ready');
|
||||
await next.release();
|
||||
assert.deepEqual(env.switches, [
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
]);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('an unavailable comparison stack reports failure and release leaves the map alone', async () => {
|
||||
const env = leaseFixture();
|
||||
const errors = [];
|
||||
env.controller._onError = (message) => errors.push(message);
|
||||
const esri = env.controller._sources.get('esri-imagery');
|
||||
esri.available = false;
|
||||
esri.unavailableReason = 'Esri offline';
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.deepEqual(await lease.ready, {
|
||||
status: 'failed',
|
||||
activeId: 'photoreal',
|
||||
});
|
||||
await lease.release();
|
||||
assert.deepEqual(env.switches, ['esri-imagery']);
|
||||
assert.deepEqual(errors, ['Esri offline']);
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('a lease stays owned until its restoration settles, so a rival cannot read the comparison stack as its own', async () => {
|
||||
const env = leaseFixture();
|
||||
const lease = env.controller.acquireImageryComparison({
|
||||
owner: 'recent',
|
||||
switchPolicy: 'esri-if-photoreal',
|
||||
});
|
||||
assert.equal((await lease.ready).activeId, 'esri-imagery');
|
||||
// Hold the restore switch open: the map is still on Esri while it runs.
|
||||
const setStack = env.controller.setStack;
|
||||
let finishRestore;
|
||||
env.controller.setStack = (id, options) =>
|
||||
new Promise((resolve) => {
|
||||
finishRestore = () => resolve(setStack(id, options));
|
||||
});
|
||||
const releasing = lease.release();
|
||||
assert.equal(typeof finishRestore, 'function', 'the restore was issued');
|
||||
assert.throws(
|
||||
() =>
|
||||
env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
}),
|
||||
/held by recent/,
|
||||
'the pending restoration still owns the lease',
|
||||
);
|
||||
finishRestore();
|
||||
await releasing;
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.setStack = setStack;
|
||||
const next = env.controller.acquireImageryComparison({
|
||||
owner: 'nepal',
|
||||
switchPolicy: 'esri',
|
||||
});
|
||||
assert.deepEqual(await next.ready, {
|
||||
status: 'ready',
|
||||
activeId: 'esri-imagery',
|
||||
});
|
||||
await next.release();
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
assert.deepEqual(env.switches, [
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
'esri-imagery',
|
||||
'photoreal',
|
||||
]);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('subscribe hears every settled activation — silent switches, fallbacks and recoveries — until unsubscribed or destroyed', async () => {
|
||||
const env = publicFixture();
|
||||
const heard = [];
|
||||
const off = env.controller.subscribe((state) =>
|
||||
heard.push([state.activeId, state.status]),
|
||||
);
|
||||
assert.equal(typeof off, 'function');
|
||||
assert.equal(typeof env.controller.subscribe(null), 'function');
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.deepEqual(heard, [['esri-imagery', 'ready']]);
|
||||
// A silent switch mutes onChange but not the subscription.
|
||||
const changesBefore = env.changes.length;
|
||||
await env.controller.setStack('photoreal', { silent: true });
|
||||
assert.equal(env.changes.length, changesBefore, 'onChange stays silent');
|
||||
assert.deepEqual(heard.at(-1), ['photoreal', 'ready']);
|
||||
// A subscriber that throws does not stop the others.
|
||||
const noisy = env.controller.subscribe(() => {
|
||||
throw new Error('listener failed');
|
||||
});
|
||||
const warn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
await env.controller.setStack('esri-imagery');
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.deepEqual(heard.at(-1), ['esri-imagery', 'ready']);
|
||||
noisy();
|
||||
off();
|
||||
await env.controller.setStack('photoreal');
|
||||
assert.equal(heard.length, 3, 'unsubscribed listeners hear nothing');
|
||||
env.controller.destroy();
|
||||
|
||||
// A failed activation that recovers still settles once, on the recovery.
|
||||
const recovering = fixture(
|
||||
{
|
||||
defaultId: 'first',
|
||||
recoveryId: 'recovery',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('first'),
|
||||
imagery: async () => {
|
||||
throw new Error('first offline');
|
||||
},
|
||||
},
|
||||
{ descriptor: descriptor('recovery'), imagery: async () => ({}) },
|
||||
],
|
||||
},
|
||||
{ onError: () => {} },
|
||||
);
|
||||
const recovered = [];
|
||||
recovering.controller.subscribe((state) => recovered.push(state.activeId));
|
||||
await recovering.controller.setStack('first');
|
||||
assert.deepEqual(recovered, ['recovery']);
|
||||
recovering.controller.destroy();
|
||||
await recovering.controller.setStack('first');
|
||||
assert.deepEqual(recovered, ['recovery'], 'a destroyed controller is mute');
|
||||
});
|
||||
|
||||
test('each switch generation reports its origin: an outside setStack is manual, a fallback or recovery automatic', async () => {
|
||||
const env = publicFixture();
|
||||
const heard = [];
|
||||
env.controller.subscribe((state) =>
|
||||
heard.push([state.activeId, state.switchOrigin]),
|
||||
);
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.controller.getSwitchOrigin(), 'manual');
|
||||
const generation = env.controller.getSwitchGeneration();
|
||||
// Two tile failures: the controller falls back to OSM on its own.
|
||||
const errors = env.providers.get('esri-imagery').errorEvent;
|
||||
errors.raise();
|
||||
errors.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(env.controller.getSwitchGeneration(), generation + 1);
|
||||
assert.equal(env.controller.getSwitchOrigin(), 'automatic');
|
||||
assert.equal(env.controller.getState().switchOrigin, 'automatic');
|
||||
// A silent switch from outside is still the caller's choice.
|
||||
await env.controller.setStack('photoreal', { silent: true });
|
||||
assert.equal(env.controller.getSwitchOrigin(), 'manual');
|
||||
assert.deepEqual(heard, [
|
||||
['esri-imagery', 'manual'],
|
||||
['osm', 'automatic'],
|
||||
['photoreal', 'manual'],
|
||||
]);
|
||||
env.controller.destroy();
|
||||
|
||||
// A failed activation that recovers settles as automatic.
|
||||
const recovering = fixture(
|
||||
{
|
||||
defaultId: 'first',
|
||||
recoveryId: 'recovery',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('first'),
|
||||
imagery: async () => {
|
||||
throw new Error('first offline');
|
||||
},
|
||||
},
|
||||
{ descriptor: descriptor('recovery'), imagery: async () => ({}) },
|
||||
],
|
||||
},
|
||||
{ onError: () => {} },
|
||||
);
|
||||
const recovered = [];
|
||||
recovering.controller.subscribe((state) =>
|
||||
recovered.push([state.activeId, state.switchOrigin]),
|
||||
);
|
||||
await recovering.controller.setStack('first');
|
||||
assert.equal(recovering.controller.getActiveId(), 'recovery');
|
||||
assert.equal(recovering.controller.getSwitchOrigin(), 'automatic');
|
||||
await recovering.controller.setStack('recovery');
|
||||
assert.equal(recovering.controller.getSwitchOrigin(), 'manual');
|
||||
assert.deepEqual(recovered, [
|
||||
['recovery', 'automatic'],
|
||||
['recovery', 'manual'],
|
||||
]);
|
||||
recovering.controller.destroy();
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/** Basemap that before/after imagery comparisons are read against. */
|
||||
export const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery';
|
||||
|
||||
const SWITCH_POLICIES = new Set(['preserve', 'esri', 'esri-if-photoreal']);
|
||||
const leases = new WeakMap();
|
||||
|
||||
function comparisonTargetId(activeId, switchPolicy) {
|
||||
if (!SWITCH_POLICIES.has(switchPolicy))
|
||||
throw new Error(
|
||||
`Unknown imagery comparison switch policy: ${switchPolicy}`,
|
||||
);
|
||||
if (switchPolicy === 'esri' && activeId !== IMAGERY_COMPARISON_STACK_ID)
|
||||
return IMAGERY_COMPARISON_STACK_ID;
|
||||
if (switchPolicy === 'esri-if-photoreal' && activeId === 'photoreal')
|
||||
return IMAGERY_COMPARISON_STACK_ID;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lease the map for an imagery comparison.
|
||||
*
|
||||
* Works on any controller exposing getActiveId(), getSwitchGeneration() and
|
||||
* setStack(id); MapSourceController#acquireImageryComparison delegates here.
|
||||
* One lease per controller: acquiring while another owner holds it throws
|
||||
* synchronously. The previous stack id and the switch generation are recorded
|
||||
* the moment the switch is issued (not after awaiting it), so release() can
|
||||
* tell its own switch from a later operator choice. `ready` resolves to
|
||||
* `{ status: 'ready' | 'superseded' | 'failed', activeId }`; a resolved
|
||||
* setStack() does not mean success because the controller falls back
|
||||
* internally. release() is idempotent and restores the previous stack only
|
||||
* while the lease's own generation still owns the active stack; releasing
|
||||
* during acquisition re-issues the previous stack so the late activation
|
||||
* cannot survive. The lease stays owned until that restoration settles (a
|
||||
* rival acquiring meanwhile still throws), so nobody can read the comparison
|
||||
* stack as their own while it is on its way out; a release with nothing to
|
||||
* restore frees the lease synchronously.
|
||||
*/
|
||||
export function acquireImageryComparison(
|
||||
controller,
|
||||
{ owner = 'imagery-comparison', switchPolicy = 'preserve' } = {},
|
||||
) {
|
||||
if (!controller)
|
||||
throw new Error('Imagery comparison needs a map stack controller');
|
||||
const held = leases.get(controller);
|
||||
if (held)
|
||||
throw new Error(
|
||||
held.releasing
|
||||
? `Imagery comparison is still held by ${held.owner} until its release settles`
|
||||
: `Imagery comparison is already held by ${held.owner}`,
|
||||
);
|
||||
const previousId = controller.getActiveId?.() ?? null;
|
||||
const targetId = comparisonTargetId(previousId, switchPolicy);
|
||||
const lease = {
|
||||
owner,
|
||||
generation: null,
|
||||
acquiring: false,
|
||||
releasing: null,
|
||||
};
|
||||
leases.set(controller, lease);
|
||||
const activeId = () => controller.getActiveId?.() ?? null;
|
||||
const generation = () => controller.getSwitchGeneration?.() ?? null;
|
||||
|
||||
let ready;
|
||||
if (targetId) {
|
||||
lease.acquiring = true;
|
||||
let switching;
|
||||
try {
|
||||
switching = Promise.resolve(controller.setStack(targetId));
|
||||
} catch (error) {
|
||||
switching = Promise.reject(error);
|
||||
}
|
||||
lease.generation = generation();
|
||||
const settle = () => {
|
||||
lease.acquiring = false;
|
||||
};
|
||||
ready = switching.then(
|
||||
() => {
|
||||
settle();
|
||||
const active = activeId();
|
||||
const status =
|
||||
generation() !== lease.generation
|
||||
? 'superseded'
|
||||
: active === targetId
|
||||
? 'ready'
|
||||
: 'failed';
|
||||
return { status, activeId: active };
|
||||
},
|
||||
(error) => {
|
||||
settle();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ready = Promise.resolve({ status: 'ready', activeId: previousId });
|
||||
}
|
||||
|
||||
const disown = () => {
|
||||
if (leases.get(controller) === lease) leases.delete(controller);
|
||||
};
|
||||
const release = () => {
|
||||
if (lease.releasing) return lease.releasing;
|
||||
const restore =
|
||||
Boolean(targetId) &&
|
||||
previousId != null &&
|
||||
previousId !== targetId &&
|
||||
generation() === lease.generation &&
|
||||
(lease.acquiring || activeId() === targetId);
|
||||
if (!restore) {
|
||||
disown();
|
||||
lease.releasing = Promise.resolve();
|
||||
return lease.releasing;
|
||||
}
|
||||
lease.releasing = (async () => {
|
||||
try {
|
||||
await controller.setStack(previousId);
|
||||
} finally {
|
||||
disown();
|
||||
}
|
||||
})();
|
||||
return lease.releasing;
|
||||
};
|
||||
|
||||
return { ready, release };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Where draped imagery can go on the current map stack. A globe stack drapes
|
||||
* on `viewer.imageryLayers`; a 3D-tiles stack hides the globe, and Cesium
|
||||
* 1.138 drapes imagery onto the tileset's own `imageryLayers` instead. When
|
||||
* neither exists there is nowhere to drape and the caller says so.
|
||||
*/
|
||||
|
||||
/** Guidance shown when the active map stack cannot host draped imagery. */
|
||||
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
|
||||
|
||||
/**
|
||||
* Resolve the imagery layer collection for the active map stack.
|
||||
* @param {{ viewer?: object, tileset?: object }} scene
|
||||
* @returns {{ collection: object | null, kind: 'globe' | 'tileset' | 'none' }}
|
||||
*/
|
||||
export function resolveImageryHost({ viewer, tileset } = {}) {
|
||||
if (viewer?.scene?.globe?.show === true && viewer.imageryLayers) {
|
||||
return { collection: viewer.imageryLayers, kind: 'globe' };
|
||||
}
|
||||
if (tileset?.imageryLayers && !tileset.isDestroyed?.()) {
|
||||
return { collection: tileset.imageryLayers, kind: 'tileset' };
|
||||
}
|
||||
return { collection: null, kind: 'none' };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NO_IMAGERY_HOST, resolveImageryHost } from './imageryHost.js';
|
||||
|
||||
test('a shown globe hosts imagery on the viewer collection', () => {
|
||||
const imageryLayers = { id: 'globe' };
|
||||
const host = resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: true } }, imageryLayers },
|
||||
tileset: { imageryLayers: { id: 'tiles' } },
|
||||
});
|
||||
assert.deepEqual(host, { collection: imageryLayers, kind: 'globe' });
|
||||
});
|
||||
|
||||
test('a hidden globe hands imagery to the tileset collection', () => {
|
||||
const imageryLayers = { id: 'tiles' };
|
||||
const host = resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } }, imageryLayers: {} },
|
||||
tileset: { imageryLayers, isDestroyed: () => false },
|
||||
});
|
||||
assert.deepEqual(host, { collection: imageryLayers, kind: 'tileset' });
|
||||
});
|
||||
|
||||
test('no globe and no tileset means nowhere to drape', () => {
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } }, imageryLayers: {} },
|
||||
tileset: null,
|
||||
}),
|
||||
{ collection: null, kind: 'none' },
|
||||
);
|
||||
assert.deepEqual(resolveImageryHost({}), { collection: null, kind: 'none' });
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } } },
|
||||
tileset: { imageryLayers: {}, isDestroyed: () => true },
|
||||
}),
|
||||
{ collection: null, kind: 'none' },
|
||||
);
|
||||
assert.match(NO_IMAGERY_HOST, /globe map/);
|
||||
});
|
||||
Reference in new issue
Block a user