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:
Bilawal Sidhuandmanjunath22466 authored and GitHub committed 2026-09-22 20:34:11 -05:00
1 parent 25491f7e33
commit 082074a006
50 files changed
+13169 -440

No files matched your search

+75 -3
View File
@@ -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;
+381
View File
@@ -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();
});
+124
View File
@@ -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 };
}
+24
View File
@@ -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' };
}
+40
View File
@@ -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/);
});