Files
gods-eye-view/src/layers/recentImagery/index.js
T

1505 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Recent Imagery layer: pick a box, list the last 30 days of HLS (Sentinel-2 /
* Landsat) and VIIRS imagery that touch it, and drape the days the operator
* pins. No polling — work happens when the box changes. Rendering, thumbnails
* and the catalog are injected, so the state machine runs under `node:test`.
*
* The mode is explicit. IMAGE shows one pinned day (slot `a`); VS BASEMAP
* swipes that day (left) against the basemap (right); A / B swipes day `a`
* (left) against day `b` (right). While the mode's slot is empty the focused
* day previews in it. Two draped layers at most, and while any image is on
* the map it borrows the Esri surface on Google 3D, and keeps it: a manual
* switch back to Google 3D while a day is shown is answered with a new lease;
* the map controller's own fallback to Google 3D is not, so a failing Esri
* map is never retried in a loop.
*/
import { searchHls } from './catalog.js';
import { NO_IMAGERY_HOST, resolveImageryHost } from '../../maps/imageryHost.js';
import {
CATALOG_DAYS,
PRODUCTS,
boxCentre,
boxFromPin,
boxFromRectangle,
boxSideKm,
dequantizeBox,
fitViewHeightM,
formatCandidateReadout,
parseCandidateKey,
quantizeBox,
rankLatest,
utcDay,
validateBox,
viewTooLargeMessage,
} from './model.js';
import { msg, t } from '../../i18n/index.js';
import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
/**
* Quiet time after the last arrow key before the focused day previews, so a
* burst of key presses drapes only the day it lands on.
*/
export const FOCUS_DEBOUNCE_MS = 250;
/** Quiet time after the last divider move before the split is published. */
export const SPLIT_PUBLISH_MS = 400;
/** The three modes, in share-link order (`m` = index). */
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
// Kept English in the snapshot (AI tools read it); the card translates them.
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map');
const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D');
/** ZOOM IN's flight time, in seconds. */
const ZOOM_FIT_SECONDS = 1.2;
const DEFAULT_SPLIT = 0.5;
const SLOT_IDS = ['a', 'b'];
const BOX_EDGES = ['west', 'south', 'east', 'north'];
const OTHER_SLOT = { a: 'b', b: 'a' };
const clampUnit = (value, fallback) => {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : fallback;
};
const boxSignature = (box) =>
box ? BOX_EDGES.map((edge) => Number(box[edge]).toFixed(6)).join(',') : '';
const sourceOf = (product) => PRODUCTS[product]?.source || null;
const toDegrees = (radians) => (radians * 180) / Math.PI;
const toRadians = (degrees) => (degrees * Math.PI) / 180;
const ellipsoidOf = (viewer) =>
viewer?.scene?.ellipsoid || viewer?.scene?.globe?.ellipsoid || null;
/**
* The ground under the canvas centre, in degrees; null when the centre is
* sky or the viewer cannot pick.
* @param {object} viewer
* @returns {{ lon: number, lat: number } | null}
*/
function viewCentre(viewer) {
const ellipsoid = ellipsoidOf(viewer);
const canvas = viewer?.scene?.canvas;
const width = Number(canvas?.clientWidth);
const height = Number(canvas?.clientHeight);
if (!ellipsoid?.cartesianToCartographic || !(width > 0) || !(height > 0))
return null;
const hit = viewer.camera?.pickEllipsoid?.(
{ x: width / 2, y: height / 2 },
ellipsoid,
);
const ground = hit ? ellipsoid.cartesianToCartographic(hit) : null;
return ground
? { lon: toDegrees(ground.longitude), lat: toDegrees(ground.latitude) }
: null;
}
const modeFrom = (value) =>
MODES.includes(value)
? value
: Number.isInteger(Number(value)) && value !== '' && value !== null
? MODES[Number(value)] || null
: null;
/**
* Create the layer.
* @param {{ catalog?: { searchHls: Function }, renderer: object, thumbnails: object, host?: Function, now?: () => Date, days?: number, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} options
*/
export function createRecentImageryLayer({
catalog = { searchHls },
renderer,
rendererFactory,
thumbnails,
host = resolveImageryHost,
now = () => new Date(),
days = CATALOG_DAYS,
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
} = {}) {
let _viewer = null;
let _engine = null;
let _tileset = null;
let _mapStackController = null;
let _dataManager = null;
let _enabled = false;
let _destroyed = false;
let _applyingParams = false;
let _box = null;
/** The refusal notice; persists until the next successful box. */
let _boxError = null;
/** Where ZOOM IN flies for an oversized box or view (degrees), or null. */
let _refusalTarget = null;
let _candidates = [];
let _truncated = false;
let _catalogErrors = [];
let _catalogError = null;
let _searching = false;
let _searchAbort = null;
let _lastUpdate = null;
const _sources = { hls: true, viirs: false };
let _showUnavailable = false;
let _focusKey = null;
let _visible = { first: 0, last: 5 };
let _mode = 'image';
const _assigned = { a: null, b: null };
/** The focused day shown while the mode's slot is empty. */
let _previewKey = null;
/** A preview armed by the arrow-key debounce: `{ key }`, key may be null. */
let _pending = null;
let _pendingTimer = null;
/** A focused day still being probed: it previews once present. */
let _followKey = null;
let _auto = null;
let _notice = null;
let _split = DEFAULT_SPLIT;
let _splitFromLink = false;
let _splitTimer = null;
let _swipeKind = 'none';
/**
* SWAP: the two sides of the divider traded (A right and B left, or the
* image right of the basemap). Session-only: the share link never carries
* it, and every new comparison starts unswapped.
*/
let _swapped = false;
let _alpha = 1;
let _lease = null;
let _leaseFromPhotoreal = false;
/** The stack the lease switched to, once ready. */
let _leaseStackId = null;
/** The controller's switch generation right after the lease was taken. */
let _leaseGeneration = null;
/** The last switch generation answered with a new lease. */
let _keptGeneration = null;
/**
* The lease was let go because the map controller fell back to Google 3D
* on its own; the next manual switch to Google 3D may take Esri again.
*/
let _droppedByFallback = false;
let _releasing = null;
let _comparisonReady = false;
let _comparisonError = null;
let _hostInfo = { collection: null, kind: 'none' };
let _toolActive = false;
let _toolHandler = null;
let _rowListener = null;
let _unsubscribeThumbnails = null;
let _unsubscribeMap = null;
const _listeners = new Set();
const candidateFor = (key) =>
(key && _candidates.find((candidate) => candidate.key === key)) || null;
const sourceOn = (candidate) =>
Boolean(candidate && _sources[sourceOf(candidate.product)]);
/** Worldview's Data-Present header is the only proof a day has pixels. */
const availability = (candidate) => {
const status = thumbnails.get(candidate.key)?.status;
return status === 'empty' || status === 'present'
? status
: candidate.availability;
};
const withAvailability = (candidate) => ({
...candidate,
availability: availability(candidate),
});
const drapable = (candidate) =>
sourceOn(candidate) && availability(candidate) === 'present';
const sourcedCandidates = () => _candidates.filter(sourceOn);
/** Source-on candidates, minus confirmed-empty days unless asked for. */
const stripList = () =>
_showUnavailable
? sourcedCandidates()
: sourcedCandidates().filter((c) => availability(c) !== 'empty');
const focusIndexIn = (list) =>
_focusKey ? list.findIndex((candidate) => candidate.key === _focusKey) : -1;
/** The slot a pinned day occupies in the current mode, if any. */
const activeSlotOf = (key) =>
!key
? null
: key === _assigned.a
? 'a'
: _mode === 'ab' && key === _assigned.b
? 'b'
: null;
/** The slot a preview fills: the mode's first empty slot. */
function previewSlot() {
if (_mode !== 'ab') return _assigned.a ? null : 'a';
if (!_assigned.a) return 'a';
return _assigned.b ? null : 'b';
}
/** What the map shows: one key per renderer slot, and the swipe kind. */
function plan() {
const out = { a: null, b: null, preview: null, swipe: 'none' };
if (!_enabled || !_box) return out;
const live = (key) => (drapable(candidateFor(key)) ? key : null);
out.a = live(_assigned.a);
if (_mode === 'ab') out.b = live(_assigned.b);
const slot = previewSlot();
const preview =
slot && !activeSlotOf(_previewKey) ? live(_previewKey) : null;
if (preview) {
out[slot] = preview;
out.preview = slot;
}
if (_mode === 'basemap' && out.a) out.swipe = 'basemap';
else if (_mode === 'ab' && out.a && out.b) out.swipe = 'ab';
return out;
}
/**
* Keep focus on the same day across probe results, source toggles and the
* availability filter; when its card leaves the strip, move to the nearest
* card still shown (ties go to the older day).
*/
function reconcileFocus() {
const list = stripList();
if (!list.length) {
_focusKey = null;
return;
}
if (focusIndexIn(list) >= 0) return;
const previous = _candidates.findIndex((c) => c.key === _focusKey);
let best = list[0];
if (previous >= 0) {
let bestDistance = Infinity;
for (const candidate of list) {
const index = _candidates.indexOf(candidate);
const distance = Math.abs(index - previous);
if (
distance < bestDistance ||
(distance === bestDistance && index > previous)
) {
best = candidate;
bestDistance = distance;
}
}
}
_focusKey = best.key;
}
const rank = (list) =>
rankLatest(list.map(withAvailability), { truncated: _truncated });
/** Focus the START HERE day and preview it when the mode's slot is empty. */
function autoPreview() {
const ranked = rank(sourcedCandidates());
if (!ranked.candidate) return;
const key = ranked.candidate.key;
if (!_focusKey) _focusKey = key;
if (!previewSlot() || activeSlotOf(key)) return;
_auto = { key, reason: ranked.reason, certain: ranked.certain };
if (drapable(candidateFor(key))) _previewKey = key;
else _followKey = key;
}
/**
* A day whose probe came back empty leaves the map: JPEG products have no
* alpha and would drape black "no data" tiles. A pinned day is unpinned and
* an empty automatic preview is re-ranked.
*/
function pruneEmpty() {
let changed = false;
for (const slotId of SLOT_IDS) {
const pinned = candidateFor(_assigned[slotId]);
if (pinned && availability(pinned) === 'empty') {
_assigned[slotId] = null;
changed = true;
}
}
const preview = candidateFor(_previewKey);
if (preview && availability(preview) === 'empty') {
const wasAuto = _auto?.key === preview.key;
_previewKey = null;
_auto = null;
changed = true;
if (wasAuto) autoPreview();
}
// A followed day that turns out empty is dropped; an automatic one hands
// over to the next ranked day.
const followed = candidateFor(_followKey);
if (followed && availability(followed) === 'empty') {
const wasAuto = _auto?.key === followed.key;
_followKey = null;
if (wasAuto) {
_auto = null;
autoPreview();
}
changed = true;
}
return changed;
}
function notify(reason = 'state') {
if (_destroyed) return;
reconcileFocus();
const snapshot = _listeners.size ? layer.getSnapshot() : null;
for (const listener of [..._listeners]) {
try {
listener(snapshot, reason);
} catch (error) {
console.warn('[Data:RecentImagery] listener failed:', error);
}
}
if (reason === 'state') _rowListener?.();
}
/**
* Publish what a direct interaction changed, so the durable state and the
* share link follow it. Never while applying params (a restore): a nested
* intent would supersede the one being applied.
*/
function publish(keys) {
if (_applyingParams || _destroyed || !_dataManager?.adoptLayerParams)
return;
const params = layer.getParams();
const picked = {};
for (const key of keys) picked[key] = params[key];
try {
_dataManager.adoptLayerParams(layer.id, picked, { origin: 'user' });
} catch (error) {
console.warn('[Data:RecentImagery] publish failed:', error);
}
}
function clearSplitTimer() {
if (_splitTimer !== null) clearTimeoutImpl(_splitTimer);
_splitTimer = null;
}
function syncHost() {
const next = renderer?.resolveHost?.() ||
host({ viewer: _viewer, tileset: _tileset }) || {
collection: null,
kind: 'none',
};
const changed =
(next.identity || next.collection) !==
(_hostInfo.identity || _hostInfo.collection) ||
next.kind !== _hostInfo.kind;
_hostInfo = next;
renderer?.rebind(next);
return changed;
}
/**
* Hand the lease back. The release restores the map asynchronously; no new
* lease is taken until it settles, and then the state is reconciled, so a
* new image inside that window re-acquires.
*/
function releaseComparison() {
const lease = _lease;
forgetLease();
if (_notice === ESRI_KEPT) _notice = null;
if (!lease) return;
const releasing = Promise.resolve()
.then(() => lease.release())
.catch((error) => {
console.warn('[Data:RecentImagery] comparison release failed:', error);
})
.then(() => {
if (_releasing === releasing) _releasing = null;
if (_destroyed) return;
if (_enabled) syncRender();
notify();
});
_releasing = releasing;
}
function forgetLease() {
_droppedByFallback = false;
_lease = null;
_leaseFromPhotoreal = false;
_leaseStackId = null;
_leaseGeneration = null;
_comparisonReady = false;
_comparisonError = null;
}
function acquireComparison() {
if (_lease || _releasing || _comparisonError || _comparisonReady) return;
if (!_mapStackController?.acquireImageryComparison) {
_comparisonReady = true;
return;
}
const fromPhotoreal = _mapStackController.getActiveId?.() === 'photoreal';
let lease;
try {
lease = _mapStackController.acquireImageryComparison({
owner: 'recent-imagery',
switchPolicy: 'esri-if-photoreal',
});
} catch {
_comparisonError = COMPARISON_IN_USE;
return;
}
_lease = lease;
_leaseFromPhotoreal = fromPhotoreal;
// Read after the lease issued its switch, so that switch is known as ours.
_leaseGeneration = _mapStackController.getSwitchGeneration?.() ?? null;
Promise.resolve(lease.ready)
.catch(() => null)
.then((result) => {
if (_destroyed || _lease !== lease) return;
if (result?.status === 'ready') {
_comparisonReady = true;
_leaseStackId = result.activeId ?? null;
} else _comparisonError = COMPARISON_UNAVAILABLE;
if (_enabled) syncRender({ comparison: false });
notify();
});
}
/**
* While a day is shown the imagery keeps the Esri surface. A manual switch
* (the operator's, not the lease's own) that lands on Google 3D is
* answered at once with a new lease under the same policy; the old lease
* no longer owns the map, so it lets go without a switch of its own, and
* the new one remembers Google 3D for CLEAR. An automatic one (the
* controller's fallback or recovery after Esri failed) is not retried: the
* lease lets go, restoring nothing, and the days show without a swipe
* until the operator switches to Google 3D by hand. Once per switch
* generation, never while releasing (CLEAR and disable return to Google
* 3D), and never without a lease to renew or one a fallback let go, so a
* lease another owner holds stays refused.
* @returns {boolean} Whether the lease was renewed, refused or let go.
*/
function keepEsri() {
const controller = _mapStackController;
if (!_enabled || _destroyed || _releasing) return false;
if (!_lease && !_droppedByFallback) return false;
if (controller?.getActiveId?.() !== 'photoreal') return false;
const shown = plan();
if (!shown.a && !shown.b) return false;
const generation = controller.getSwitchGeneration?.() ?? null;
if (generation === _leaseGeneration || generation === _keptGeneration)
return false;
_keptGeneration = generation;
const automatic = controller.getSwitchOrigin?.() === 'automatic';
const previous = _lease;
forgetLease();
try {
void previous?.release();
} catch (error) {
console.warn('[Data:RecentImagery] comparison release failed:', error);
}
if (automatic) {
_droppedByFallback = true;
_comparisonError = COMPARISON_UNAVAILABLE;
if (_notice === ESRI_KEPT) _notice = null;
return true;
}
acquireComparison();
if (_lease) _notice = ESRI_KEPT;
else if (_notice === ESRI_KEPT) _notice = null;
return true;
}
/**
* A settled stack switch (or, without a subscription, the stats poll):
* rebind to the new host and keep the Esri surface.
*/
function followMap() {
if (_destroyed || !_enabled) return;
const moved = syncHost();
if (!keepEsri() && !moved) return;
syncRender();
notify();
}
const swipeGranted = () => _comparisonReady && !_comparisonError;
/** Whether the divider is live for this plan: granted, on the globe. */
const swipeLive = (shown) =>
shown.swipe !== 'none' && swipeGranted() && _hostInfo.kind === 'globe';
/**
* Every new comparison starts unswapped with the divider centred, except
* once for a split restored from a share link (the author's framing).
* Scrubbing the second image inside a comparison keeps both as they are.
*/
function trackSwipeStart(kind) {
if (kind !== 'none' && kind !== _swipeKind) {
_swapped = false;
if (_splitFromLink) _splitFromLink = false;
else if (_split !== DEFAULT_SPLIT) {
_split = DEFAULT_SPLIT;
clearSplitTimer();
publish(['split']);
notify('split');
}
}
_swipeKind = kind;
}
function syncRender({ comparison = true } = {}) {
if (_destroyed || !renderer) return;
syncHost();
pruneEmpty();
const shown = plan();
trackSwipeStart(shown.swipe);
const live = swipeLive(shown);
renderer.setSplit?.(_split);
for (const slotId of SLOT_IDS) {
const candidate = candidateFor(shown[slotId]);
if (candidate && (_hostInfo.identity || _hostInfo.collection)) {
renderer.showSlot(slotId, candidate, _box, {
alpha: _alpha,
splitDirection: !live
? 'none'
: (slotId === 'a') !== _swapped
? 'left'
: 'right',
});
} else {
renderer.hideSlot(slotId);
}
}
if (!comparison) return;
if (shown.a || shown.b) acquireComparison();
else if (_lease || _comparisonReady || _comparisonError)
releaseComparison();
}
/**
* Probe the visible strip window, then the pinned and previewed days
* wherever they sit: a pin restored or assigned off-screen never drapes
* until its probe says present, so it goes at the focused card's priority.
*/
function requestThumbnails() {
if (!_enabled || !_box) return;
const list = stripList();
if (!list.length) return;
reconcileFocus();
thumbnails.requestOrdered(list, _box, {
focusIndex: Math.max(0, focusIndexIn(list)),
firstVisible: _visible.first,
lastVisible: _visible.last,
extra: 2,
});
for (const key of new Set([
_assigned.a,
_assigned.b,
_previewKey,
_followKey,
])) {
const candidate = candidateFor(key);
if (sourceOn(candidate)) thumbnails.request(candidate, _box, 0);
}
}
function resetCatalog() {
_candidates = [];
_truncated = false;
_catalogErrors = [];
_catalogError = null;
_auto = null;
}
function cancelSearch() {
_searchAbort?.abort();
_searchAbort = null;
_searching = false;
}
async function runSearch() {
cancelSearch();
const abort = new AbortController();
_searchAbort = abort;
_searching = true;
_catalogError = null;
notify();
try {
const result = await catalog.searchHls({
box: _box,
days,
now: now(),
signal: abort.signal,
});
if (abort.signal.aborted) return;
_candidates = result?.candidates || [];
_truncated = Boolean(result?.truncated);
_catalogErrors = result?.errors || [];
_lastUpdate = Date.now();
// Drop days the catalog no longer lists; start from a pinned day or
// the recommended one.
for (const slotId of SLOT_IDS)
if (!candidateFor(_assigned[slotId])) _assigned[slotId] = null;
_previewKey = null;
_auto = null;
_focusKey =
[_assigned.a, _assigned.b].find((key) => activeSlotOf(key)) || null;
if (!_focusKey) autoPreview();
} catch (error) {
if (abort.signal.aborted) return;
resetCatalog();
_catalogError = error?.message || msg('Imagery catalog unavailable');
} finally {
if (_searchAbort === abort) {
_searchAbort = null;
_searching = false;
}
}
syncRender();
requestThumbnails();
notify();
}
function clearPending() {
if (_pendingTimer !== null) clearTimeoutImpl(_pendingTimer);
_pendingTimer = null;
_pending = null;
}
/** Make `key` (or nothing) the preview now. */
function applyPreview(key) {
clearPending();
_followKey = null;
if (key === _previewKey) return false;
if (_auto?.key !== key) _auto = null;
_previewKey = key;
if (_enabled) syncRender();
return true;
}
/** Arm the debounce for the focused day, or follow it until it is probed. */
function schedulePreviewFromFocus() {
clearPending();
_followKey = null;
const candidate = candidateFor(_focusKey);
if (!candidate || !_enabled || !previewSlot()) return;
// A pinned day is already on the map; focusing it takes the preview off.
const key = activeSlotOf(candidate.key) ? null : candidate.key;
if (key === _previewKey) return;
if (key && !drapable(candidate)) {
_followKey = key;
return;
}
_pending = { key };
_pendingTimer = setTimeoutImpl(() => {
_pendingTimer = null;
const pending = _pending;
_pending = null;
if (_destroyed || !pending) return;
if (pending.key && !drapable(candidateFor(pending.key))) return;
if (applyPreview(pending.key)) notify();
}, FOCUS_DEBOUNCE_MS);
}
/**
* @param {string} message
* @param {{ lon: number, lat: number } | null} [target] Where ZOOM IN flies.
*/
function refuseBox(message, target = null) {
_boxError = message;
_refusalTarget = target;
notify();
return false;
}
/** ZOOM IN applies to a box over the cap only: fly to its centre. */
const oversizedCentre = (box) =>
validateBox(box).reason === 'too-large' ? boxCentre(box) : null;
function applyBox(box) {
const result = validateBox(box);
if (!result.ok) return refuseBox(result.message, oversizedCentre(box));
_boxError = null;
_refusalTarget = null;
_notice = null;
if (
boxSignature(result.box) === boxSignature(_box) &&
(_searching || _candidates.length)
) {
notify();
return true;
}
clearPending();
_followKey = null;
_previewKey = null;
_box = result.box;
thumbnails.clear();
resetCatalog();
_focusKey = null;
if (_enabled) {
// The old box's drapes come down now, not when the new search lands.
syncRender();
void runSearch();
} else notify();
return true;
}
/** A user-chosen box, published once accepted. */
function chooseBox(box) {
const accepted = applyBox(box);
if (accepted) publish(BOX_EDGES);
return accepted;
}
function guidance() {
if (_enabled && _hostInfo.kind === 'none') return NO_IMAGERY_HOST;
if (_comparisonError) return _comparisonError;
if (
_enabled &&
plan().swipe !== 'none' &&
swipeGranted() &&
_hostInfo.kind === 'tileset'
)
return COMPARISON_NEEDS_GLOBE;
return _catalogError;
}
function notes() {
const out = [];
if (_truncated)
out.push(t('Catalog truncated · a newer clear day may exist'));
for (const error of _catalogErrors) {
const label = PRODUCTS[error.product]?.label || error.product;
out.push(
t('{label} catalog unavailable · {detail}', {
label,
detail: error.message,
}),
);
}
const today = utcDay(now());
const viirsToday = candidateFor(`VIIRS:${today}`);
if (viirsToday?.availability === 'unknown' && _sources.viirs)
out.push(t('Daily overview for today may still be empty until the pass'));
return out;
}
function imageSnapshot(key) {
const parsed = parseCandidateKey(key);
const sourceOff = Boolean(parsed) && !_sources[sourceOf(parsed.product)];
return {
key: key || null,
candidate: candidateFor(key),
label: parsed
? `${displayDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${displayMetres(PRODUCTS[parsed.product].resolutionM)}`
: null,
sourceOff,
drapable: drapable(candidateFor(key)),
};
}
function applySources(next) {
let changed = false;
for (const id of ['hls', 'viirs']) {
if (next?.[id] == null || Boolean(next[id]) === _sources[id]) continue;
_sources[id] = Boolean(next[id]);
changed = true;
const pending = candidateFor(_pending?.key);
if (!_sources[id] && pending && sourceOf(pending.product) === id)
clearPending();
}
return changed;
}
const layer = {
id: 'recent-imagery',
name: 'Recent Imagery',
icon: '🛰',
source: 'NASA GIBS · HLS + VIIRS',
updateInterval: 0,
init(viewer, options) {
return this.attachRenderer(viewer, options);
},
attachRenderer(viewer, { engine } = {}) {
_viewer = viewer || null;
_engine = engine || null;
if (rendererFactory && !renderer)
renderer = rendererFactory({ viewer, engine });
if (!renderer) throw new TypeError('Recent imagery requires a renderer');
if (_enabled) syncRender();
return true;
},
async detachRenderer() {
_unsubscribeMap?.();
_unsubscribeMap = null;
_mapStackController = null;
const lease = _lease;
forgetLease();
renderer?.destroy();
renderer = null;
_hostInfo = { collection: null, kind: 'none' };
_viewer = _engine = _tileset = null;
_keptGeneration = null;
await Promise.all([lease?.release(), _releasing]);
},
enable(viewer) {
if (_destroyed) return false;
if (viewer) _viewer = viewer;
_enabled = true;
_comparisonError = null;
_unsubscribeThumbnails?.();
_unsubscribeThumbnails = thumbnails.subscribe(() => {
// A probe can take a pinned day off the map or let a followed focus
// preview; both change more than the cards, so a full notification
// follows the thumbnail one.
const pruned = pruneEmpty();
if (pruned) syncRender();
const followed =
Boolean(_followKey) &&
_followKey === _focusKey &&
drapable(candidateFor(_followKey)) &&
applyPreview(_followKey);
// A pinned or previewed day that was still being probed drapes now.
const shown = plan();
const owned = renderer?.getOwned?.() || {};
const drapesChanged = SLOT_IDS.some(
(slotId) => (owned[slotId]?.key || null) !== shown[slotId],
);
if (drapesChanged && !pruned && !followed) syncRender();
notify('thumbnail');
if (pruned || followed || drapesChanged) notify();
});
syncHost();
if (_box && !_candidates.length) void runSearch();
else {
syncRender();
requestThumbnails();
}
notify();
return true;
},
disable() {
cancelSearch();
clearPending();
_followKey = null;
thumbnails.clear();
_unsubscribeThumbnails?.();
_unsubscribeThumbnails = null;
_toolHandler?.();
_toolActive = false;
_enabled = false;
resetCatalog();
_previewKey = null;
_swipeKind = 'none';
for (const slotId of SLOT_IDS) renderer?.hideSlot(slotId);
releaseComparison();
notify();
return true;
},
/** No polling: candidates change with the box, not with the clock. */
async update() {},
destroy() {
if (_destroyed) return;
if (_enabled) this.disable();
clearPending();
clearSplitTimer();
_unsubscribeMap?.();
_unsubscribeMap = null;
renderer?.destroy();
thumbnails.destroy();
_listeners.clear();
_rowListener = null;
_toolHandler = null;
_viewer = null;
_engine = null;
_tileset = null;
_mapStackController = null;
_dataManager = null;
_destroyed = true;
},
/**
* Keep the map controller for the Esri lease and hear every settled
* stack switch, so the drapes move to the new host the moment it
* changes, and a manual switch to Google 3D while a day is shown takes
* Esri back, while an automatic fallback lets it go (`keepEsri`).
* @param {object | null} controller
*/
attachMapStackController(controller) {
_unsubscribeMap?.();
_unsubscribeMap = null;
_mapStackController = controller || null;
const off = controller?.subscribe?.(followMap);
_unsubscribeMap = typeof off === 'function' ? off : null;
},
/** The layer manager, to publish direct interactions as params. */
attachDataManager(manager) {
_dataManager = manager || null;
},
/** The photoreal tileset, for draping while the globe is hidden. */
attachTileset(tileset) {
_tileset = tileset || null;
if (_enabled && syncHost()) {
syncRender();
notify();
}
},
/** The UI registers how the box tool is cancelled (CLEAR, disable). */
setToolHandler(handler) {
_toolHandler = typeof handler === 'function' ? handler : null;
},
/** The UI reports whether the box tool is live. */
setToolActive(active) {
if (Boolean(active) === _toolActive) return;
_toolActive = Boolean(active);
notify();
},
/**
* Select a box; a refusal stays on the panel until a box succeeds.
* @param {object} box Degrees box.
* @returns {boolean}
*/
setBox(box) {
return chooseBox(box);
},
/**
* The box tool refused a drag: keep its reason on the panel. With the
* dragged box, an oversized drag offers ZOOM IN.
* @param {string} message
* @param {object} [box] Degrees box.
*/
reportBoxRefusal(message, box = null) {
const text = typeof message === 'string' ? message.trim() : '';
if (!text) return false;
refuseBox(text, box ? oversizedCentre(box) : null);
return true;
},
/**
* Use the camera's view rectangle as the box. A view wider than the cap
* is refused with its size, never silently shrunk.
* @param {object} [viewer]
* @returns {boolean}
*/
useCurrentView(viewer = _viewer) {
const rectangle =
_engine?.camera.viewRectangle() ||
viewer?.camera?.computeViewRectangle?.();
if (!rectangle)
return refuseBox('Point the camera at the ground to use the view');
const box = boxFromRectangle(rectangle);
if (validateBox(box).reason === 'too-large') {
const { width, height } = boxSideKm(box);
return refuseBox(
viewTooLargeMessage(Math.max(width, height)),
renderer?.viewCentre?.() || viewCentre(viewer) || boxCentre(box),
);
}
return chooseBox(box);
},
/**
* ZOOM IN: fly top-down to the refused box's or view's centre, at the
* height whose view is about 400 km wide for this canvas and FOV.
* @returns {number | null} The target height in metres, or null when
* there is nothing to fit.
*/
zoomToFit() {
const target = _boxError ? _refusalTarget : null;
if (target && renderer?.zoomToFit) return renderer.zoomToFit(target);
const camera = _viewer?.camera;
const ellipsoid = ellipsoidOf(_viewer);
if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian)
return null;
const canvas = _viewer.scene?.canvas;
const height = fitViewHeightM({
width: canvas?.clientWidth,
height: canvas?.clientHeight,
fovy: camera.frustum?.fovy,
});
camera.flyTo({
destination: ellipsoid.cartographicToCartesian({
longitude: toRadians(target.lon),
latitude: toRadians(target.lat),
height,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
duration: ZOOM_FIT_SECONDS,
});
return height;
},
/** The box tool's transient ground preview, owned by the renderer. */
setBoxPreview(box) {
renderer?.setBoxPreview?.(box);
},
/** A 10 km box centred on a point. */
boxFromPinAt(lon, lat) {
const box = boxFromPin(lon, lat);
return box ? chooseBox(box) : refuseBox('That point cannot anchor a box');
},
/**
* Move focus in the strip (index into the shown cards). While the mode's
* slot is empty the focused day previews after `FOCUS_DEBOUNCE_MS`, once
* it is probed present.
* @param {number} index
*/
focus(index) {
const list = stripList();
if (!list.length) return;
const next = Math.max(
0,
Math.min(list.length - 1, Math.trunc(Number(index)) || 0),
);
if (list[next].key === _focusKey) return;
_focusKey = list[next].key;
schedulePreviewFromFocus();
requestThumbnails();
notify();
},
/** The strip reports which cards are on screen. */
setVisibleRange(first, last) {
const lo = Math.max(0, Math.trunc(Number(first)) || 0);
const hi = Math.max(lo, Math.trunc(Number(last)) || lo);
if (lo === _visible.first && hi === _visible.last) return;
_visible = { first: lo, last: hi };
requestThumbnails();
},
/**
* Focus a day and preview it now (a card click). A day still being
* probed is followed; an empty day never drapes and the previous preview
* stays. With the mode's slots full the click only moves focus.
* @param {string} key
* @returns {boolean} Whether that day is now the preview.
*/
preview(key) {
const candidate = candidateFor(key);
if (!sourceOn(candidate)) return false;
if (stripList().includes(candidate)) _focusKey = candidate.key;
if (!previewSlot() || !_enabled) {
clearPending();
_followKey = null;
notify();
return false;
}
if (activeSlotOf(candidate.key)) {
applyPreview(null);
notify();
return false;
}
if (!drapable(candidate)) {
clearPending();
_followKey = availability(candidate) === 'empty' ? null : candidate.key;
notify();
return false;
}
applyPreview(candidate.key);
notify();
return true;
},
/**
* Take the preview off the map (Escape). Pinned days stay.
* @returns {boolean} Whether a preview was showing or on its way.
*/
clearPreview() {
const had = Boolean(_pending || _followKey || plan().preview);
clearPending();
_followKey = null;
if (_previewKey) {
_previewKey = null;
_auto = null;
if (_enabled) syncRender();
}
if (had) notify();
return had;
},
/**
* Pin a day in slot `a` or `b`, or unpin the slot with `null`. A day is
* in one slot at most: pinning it in the other slot moves it. Empty days
* cannot be pinned.
* @param {'a'|'b'} slot
* @param {string | null} key
* @returns {boolean} Whether the pins changed.
*/
setAssignment(slot, key) {
if (!SLOT_IDS.includes(slot)) return false;
let next = null;
if (key != null) {
const candidate = candidateFor(key);
if (!sourceOn(candidate) || availability(candidate) === 'empty')
return false;
next = candidate.key;
if (stripList().includes(candidate)) _focusKey = next;
}
if (_assigned[slot] === next) return false;
if (next && _assigned[OTHER_SLOT[slot]] === next)
_assigned[OTHER_SLOT[slot]] = null;
_assigned[slot] = next;
// The preview belonged to the old pins; the next focus move brings it back.
clearPending();
_followKey = null;
_previewKey = null;
_auto = null;
if (_enabled) {
syncRender();
requestThumbnails();
}
publish(['a', 'b']);
notify();
return true;
},
/**
* Pin `key` in `slot`, or unpin it when it is already there.
* @param {'a'|'b'} slot
* @param {string} key
* @returns {boolean} Whether the pins changed.
*/
toggleAssignment(slot, key) {
return layer.setAssignment(slot, _assigned[slot] === key ? null : key);
},
/**
* Switch the mode: `image`, `basemap` (the pinned day against the
* basemap) or `ab` (two pinned days). Pins survive a mode switch; the
* B pin only drapes in `ab`.
* @param {'image'|'basemap'|'ab'} mode
* @returns {boolean} Whether the mode changed.
*/
setMode(mode) {
const next = modeFrom(mode);
if (!next || next === _mode) return false;
_mode = next;
clearPending();
_followKey = null;
if (_enabled) syncRender();
publish(['mode']);
notify();
return true;
},
/**
* Turn the source groups on or off. Off takes their cards and drapes
* away and labels their pins "Source off"; on restores them.
* @param {{ hls?: boolean, viirs?: boolean }} next
* @returns {boolean} Whether anything changed.
*/
setSources(next) {
if (!applySources(next)) return false;
if (_enabled) {
syncRender();
requestThumbnails();
}
notify();
return true;
},
/** Show or hide confirmed-empty days in the strip. */
setShowUnavailable(value) {
if (Boolean(value) === _showUnavailable) return;
_showUnavailable = Boolean(value);
if (_enabled) requestThumbnails();
notify();
},
/** Split position 0–1; the split control owns the scene write. */
setSplit(value) {
const next = clampUnit(value, _split);
if (next === _split) return;
_split = next;
renderer?.setSplit?.(next);
clearSplitTimer();
_splitTimer = setTimeoutImpl(() => {
_splitTimer = null;
publish(['split']);
}, SPLIT_PUBLISH_MS);
notify('split');
},
/**
* SWAP: trade the two sides of a live divider (A moves right and B left;
* in VS BASEMAP the image moves right of the basemap). Again restores.
* The divider position stays; nothing is published.
* @returns {boolean} Whether a live swipe was swapped.
*/
swapSides() {
if (!_enabled || _destroyed || !swipeLive(plan())) return false;
_swapped = !_swapped;
syncRender({ comparison: false });
notify();
return true;
},
/** One opacity for both drapes. */
setAlpha(value) {
const next = clampUnit(value, _alpha);
if (next === _alpha) return;
_alpha = next;
for (const slotId of SLOT_IDS) renderer?.setAlpha(slotId, next);
notify('alpha');
},
/**
* Forget the box, the candidates, both pins, the preview and any pending
* pick; reset opacity and split and cancel the box tool. Mode and
* sources survive.
*/
clear() {
cancelSearch();
clearPending();
clearSplitTimer();
_followKey = null;
_toolHandler?.();
thumbnails.clear();
_box = null;
_boxError = null;
_refusalTarget = null;
resetCatalog();
_focusKey = null;
_assigned.a = null;
_assigned.b = null;
_previewKey = null;
_split = DEFAULT_SPLIT;
_swapped = false;
if (_alpha !== 1) {
_alpha = 1;
for (const slotId of SLOT_IDS) renderer?.setAlpha(slotId, 1);
}
_notice = msg('Box and images cleared');
syncRender();
publish([...BOX_EDGES, 'a', 'b', 'split']);
notify();
},
/** Everything the panel renders. */
getSnapshot() {
reconcileFocus();
const list = stripList();
const focusIndex = Math.max(0, focusIndexIn(list));
const ranked = rank(list);
const shown = plan();
const previewKey = shown.preview ? shown[shown.preview] : null;
const pendingKey = _pending?.key || _followKey || null;
// The focused day is its card's entry, so the readout sees the same
// map and probe state the strip does.
const candidates = list.map((candidate, index) => ({
...candidate,
index,
pinned:
candidate.key === _assigned.a
? 'a'
: candidate.key === _assigned.b
? 'b'
: null,
preview: candidate.key === previewKey,
pending: candidate.key === pendingKey,
drapable: drapable(candidate),
thumbnail: thumbnails.get(candidate.key),
}));
const focus = candidates[focusIndex] || null;
const comparing = swipeLive(shown);
return {
enabled: _enabled,
box: _box,
boxSizeKm: _box ? boxSideKm(_box) : null,
boxError: _boxError,
/** An oversized box or view: the panel offers ZOOM IN. */
zoomToFit: Boolean(_boxError && _refusalTarget),
searching: _searching,
sources: { ..._sources },
showUnavailable: _showUnavailable,
hiddenCount: _showUnavailable
? 0
: sourcedCandidates().length - list.length,
candidates,
focusIndex,
focus,
readout: focus
? formatCandidateReadout(focus, now(), READOUT_WORDS)
: '',
recommended: ranked.candidate
? { key: ranked.candidate.key, reason: ranked.reason }
: null,
mode: _mode,
pins: { a: imageSnapshot(_assigned.a), b: imageSnapshot(_assigned.b) },
preview: {
...imageSnapshot(previewKey),
slot: shown.preview,
pending: pendingKey,
},
shown: { a: shown.a, b: shown.b, swipe: shown.swipe },
alpha: _alpha,
split: _split,
swapped: _swapped,
comparison: {
active: comparing,
suspended:
shown.swipe !== 'none' &&
swipeGranted() &&
_hostInfo.kind === 'tileset',
},
// Only while the borrowed map is still the one showing: an operator
// who switched back by hand is not told the imagery is on Esri.
borrowedEsri:
Boolean(_lease) &&
_comparisonReady &&
_leaseFromPhotoreal &&
_leaseStackId !== null &&
_mapStackController?.getActiveId?.() === _leaseStackId,
notes: notes(),
auto: _auto,
toolActive: _toolActive,
error: guidance(),
notice: _notice,
};
},
/**
* @param {(snapshot: object, reason: string) => void} listener
* @returns {() => void}
*/
subscribe(listener) {
_listeners.add(listener);
return () => _listeners.delete(listener);
},
/** The data-panel row carries the two source toggles only. */
getRowControls() {
return {
chips: [
{
id: 'hls',
label: t('MORE DETAIL · 30 m'),
active: _sources.hls,
title: t('Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days'),
params: { hls: !_sources.hls },
},
{
id: 'viirs',
label: t('DAILY OVERVIEW · 250 m'),
active: _sources.viirs,
title: t(
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
),
params: { viirs: !_sources.viirs },
},
],
};
},
setRowControlsListener(listener) {
_rowListener = typeof listener === 'function' ? listener : null;
},
/**
* Share-link and row-chip options: box edges as integers at degrees ×
* 100000, the `a` and `b` pins, `mode` (0 image, 1 basemap, 2 A / B),
* `split` percent, `hls` / `viirs` source toggles.
* @param {object} params
* @returns {boolean}
*/
setParams(params) {
if (!params || typeof params !== 'object') return false;
_applyingParams = true;
try {
let changed = applySources(params);
if (BOX_EDGES.every((edge) => params[edge] != null)) {
const box = dequantizeBox(params);
if (box) applyBox(box);
}
// Read before the pins so a link's framing is in place when its
// comparison first renders. Local storage never keeps the split, so
// only a non-default value can have come from a link.
if (params.split != null && Number.isFinite(Number(params.split))) {
_split = clampUnit(Number(params.split) / 100, _split);
_splitFromLink = _split !== DEFAULT_SPLIT;
}
const mode = params.mode == null ? null : modeFrom(params.mode);
if (mode && mode !== _mode) {
_mode = mode;
changed = true;
}
if ('a' in params || 'b' in params) {
clearPending();
_followKey = null;
for (const slotId of SLOT_IDS) {
if (slotId in params)
_assigned[slotId] = parseCandidateKey(params[slotId])
? params[slotId]
: null;
}
if (_assigned.a && _assigned.a === _assigned.b) _assigned.b = null;
_previewKey = null;
_auto = null;
const pinned = _assigned.a || _assigned.b;
if (pinned && candidateFor(pinned)) _focusKey = pinned;
changed = true;
}
if (changed && _enabled) {
syncRender();
requestThumbnails();
}
notify();
return true;
} finally {
_applyingParams = false;
}
},
getParams() {
const quantized = quantizeBox(_box);
return {
west: quantized?.west ?? null,
south: quantized?.south ?? null,
east: quantized?.east ?? null,
north: quantized?.north ?? null,
a: _assigned.a,
b: _assigned.b,
mode: MODES.indexOf(_mode),
split: Math.round(_split * 100),
viirs: _sources.viirs,
};
},
getStats() {
// Without a controller subscription the stats poll is the only steady
// heartbeat, so it doubles as the map-stack watch.
if (!_unsubscribeMap) followMap();
return {
count: _candidates.length,
lastUpdate: _lastUpdate,
error: guidance(),
};
},
/** Renderer, host and lease state for tests and the live gate. */
diagnostics() {
const shown = plan();
return {
host: _hostInfo.kind,
ownedCount: renderer?.ownedCount() || 0,
renderer: renderer?.diagnostics?.(),
lease: Boolean(_lease),
releasing: Boolean(_releasing),
mode: _mode,
pins: { ..._assigned },
preview: _previewKey,
shown,
pending: _pending ? _pending.key : undefined,
following: _followKey,
splitFromLink: _splitFromLink,
};
},
};
return layer;
}