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

+32 -136
View File
@@ -22,6 +22,8 @@ import {
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -964,10 +966,7 @@ export function createBhoteKoshiEventLayer({
let _enabled = false;
let _panel = null;
let _panelRefs = null;
let _splitLine = null;
let _splitHandle = null;
let _splitDragging = false;
let _splitPointerId = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
let _imageryLayerCollection = null;
@@ -1022,14 +1021,11 @@ export function createBhoteKoshiEventLayer({
let _playStartedAt = 0;
let _playStartProgress = 0;
let _animationFrame = null;
let _previousMapStack = null;
let _eventMapGeneration = null;
let _previousSplitPosition = null;
let _comparisonLease = null;
let _introTimer = null;
let _introGeneration = 0;
let _lastSurgePosition = null;
let _lastSurgeVisible = null;
let _lastSplitCssValue = null;
let _presentation = 'standalone';
let _sceneBeatId = null;
let _sceneBeatReveal = 0.36;
@@ -2556,79 +2552,20 @@ export function createBhoteKoshiEventLayer({
createFloodDataSource();
}
function ensureSplitLine(event) {
_splitLine = document.createElement('div');
_splitLine.id = 'bhote-koshi-split-line';
const beforeLabel = document.createElement('span');
const afterLabel = document.createElement('span');
const splitHandle = document.createElement('button');
beforeLabel.className = 'before';
afterLabel.className = 'after';
beforeLabel.textContent = 'A';
beforeLabel.title = event.imagery.before.label;
afterLabel.textContent = 'B';
afterLabel.title = event.imagery.after.label;
splitHandle.type = 'button';
splitHandle.className = 'bhote-koshi-split-handle';
splitHandle.setAttribute('role', 'slider');
splitHandle.setAttribute(
'aria-label',
'Historical reference and post-event image divider',
);
splitHandle.setAttribute('aria-valuemin', '0');
splitHandle.setAttribute('aria-valuemax', '100');
splitHandle.setAttribute('aria-orientation', 'horizontal');
splitHandle.textContent = '↔';
splitHandle.addEventListener('pointerdown', handleSplitPointerDown);
splitHandle.addEventListener('pointermove', handleSplitPointerMove);
splitHandle.addEventListener('pointerup', finishSplitPointerDrag);
splitHandle.addEventListener('pointercancel', finishSplitPointerDrag);
splitHandle.addEventListener('keydown', handleSplitKeyDown);
_splitHandle = splitHandle;
_splitLine.append(beforeLabel, splitHandle, afterLabel);
document.body.appendChild(_splitLine);
}
function setSplitFromClientX(clientX) {
const viewportWidth =
Number(document.documentElement?.clientWidth) ||
Number(window.innerWidth) ||
0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
setSplit(Number(clientX) / viewportWidth);
}
function handleSplitPointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
_splitDragging = true;
_splitPointerId = event.pointerId ?? null;
_splitHandle?.setPointerCapture?.(event.pointerId);
setSplitFromClientX(event.clientX);
}
function handleSplitPointerMove(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
event.preventDefault?.();
setSplitFromClientX(event.clientX);
}
function finishSplitPointerDrag(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
_splitHandle?.releasePointerCapture?.(event.pointerId);
_splitDragging = false;
_splitPointerId = null;
}
function handleSplitKeyDown(event) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') setSplit(0);
else if (event.key === 'End') setSplit(1);
else setSplit(_split + (event.key === 'ArrowRight' ? step : -step));
function createSplitControl(event) {
_splitControl = createImagerySplit({
scene: _viewer.scene,
initialValue: _split,
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
onChange: setSplit,
});
}
function applyCinematicCamera() {
@@ -3211,7 +3148,7 @@ export function createBhoteKoshiEventLayer({
_panelRefs.observedStatus.hidden = !imageryAvailable;
if (_panelRefs?.progressInput)
_panelRefs.progressInput.hidden = !scrubberAvailable;
if (_splitLine) _splitLine.hidden = !imageryAvailable;
_splitControl?.setVisible(imageryAvailable);
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
@@ -3328,24 +3265,7 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
if (_viewer && _viewer.scene.splitPosition !== _split)
_viewer.scene.splitPosition = _split;
const cssValue = `${_split * 100}%`;
if (_lastSplitCssValue !== cssValue) {
document.documentElement.style.setProperty(
'--bhote-koshi-split',
cssValue,
);
_lastSplitCssValue = cssValue;
}
if (_splitHandle) {
const beforePercent = Math.round(_split * 100);
_splitHandle.setAttribute('aria-valuenow', String(beforePercent));
_splitHandle.setAttribute(
'aria-valuetext',
`A historical reference ${beforePercent} percent, B post-event ${100 - beforePercent} percent`,
);
}
_splitControl?.setValue(_split);
syncPanel();
renderHost.request('bhote-koshi-split');
}
@@ -3428,7 +3348,6 @@ export function createBhoteKoshiEventLayer({
async function enable(viewer, { origin = 'local-restore', signal } = {}) {
_viewer = viewer;
_enabled = true;
_previousSplitPosition = viewer.scene.splitPosition;
// Public lifecycle passes signal, not intent origin, to enable. Its atomic
// restore has already applied the scene parameters; those own presentation.
// A missing origin is passive, never authority to focus or start playback.
@@ -3437,24 +3356,20 @@ export function createBhoteKoshiEventLayer({
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
_previousMapStack = _mapStackController?.getActiveId?.() || null;
if (
!sceneDirected &&
_mapStackController &&
_previousMapStack !== 'esri-imagery'
) {
await _mapStackController.setStack('esri-imagery');
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
switchPolicy: sceneDirected ? 'preserve' : 'esri',
});
await _comparisonLease.ready;
}
_eventMapGeneration = sceneDirected
? null
: (_mapStackController?.getSwitchGeneration?.() ?? null);
throwIfEnableCancelled(signal, _enabled);
await createImagery(event);
throwIfEnableCancelled(signal, _enabled);
await syncTerrainComparison();
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
ensureSplitLine(event);
createSplitControl(event);
const panelView = createPanel(event, {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
@@ -3573,17 +3488,8 @@ export function createBhoteKoshiEventLayer({
?.classList.remove('bhote-event-active');
_panel = null;
_panelRefs = null;
_splitDragging = false;
_splitPointerId = null;
_splitLine?.remove();
_splitLine = null;
_splitHandle = null;
document.documentElement.style.removeProperty('--bhote-koshi-split');
_lastSplitCssValue = null;
if (_viewer?.scene && _previousSplitPosition != null) {
_viewer.scene.splitPosition = _previousSplitPosition;
}
_previousSplitPosition = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
_split = 0.5;
_presentation = 'standalone';
@@ -3601,20 +3507,10 @@ export function createBhoteKoshiEventLayer({
cancel?.(_sceneSeekFrame);
_sceneSeekFrame = null;
}
const previousMapStack = _previousMapStack;
const eventMapGeneration = _eventMapGeneration;
_previousMapStack = null;
_eventMapGeneration = null;
const comparisonLease = _comparisonLease;
_comparisonLease = null;
try {
if (
_mapStackController &&
previousMapStack &&
previousMapStack !== 'esri-imagery' &&
_mapStackController.getActiveId?.() === 'esri-imagery' &&
_mapStackController.getSwitchGeneration?.() === eventMapGeneration
) {
await _mapStackController.setStack(previousMapStack);
}
await comparisonLease?.release();
} finally {
renderHost.request('bhote-koshi-disable');
}
+9
View File
@@ -77,6 +77,15 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'nasa-gibs',
html:
'Recent imagery: We acknowledge the use of imagery provided by services from ' +
"NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science " +
'Data and Information System (ESDIS). ' +
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
},
{
key: 'overpass',
html:
+119 -3
View File
@@ -212,6 +212,84 @@ function integerOption(key, token, defaultValue) {
});
}
/**
* A share-link-only option: the whole-state codec carries it, but the stored
* local blob always holds its default.
*/
function shareOnlyOption(spec) {
return Object.freeze({ ...spec, shareOnly: true });
}
/**
* A signed integer in `[min, max]`, rejected (never clamped) outside it: a
* clamped box edge would be a different box, not a shorter spelling.
*/
function boundedIntegerOption(key, token, defaultValue, { min, max }) {
const parse = (value) => {
const number =
typeof value === 'number'
? value
: /^-?\d{1,9}$/.test(String(value).trim())
? Number(String(value).trim())
: NaN;
return Number.isInteger(number) && number >= min && number <= max
? number
: null;
};
return Object.freeze({
key,
token,
defaultValue,
normalize: parse,
encode: (value) => String(value),
decode: parse,
});
}
/**
* Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`.
* The calendar is checked both ways, and "today" is never consulted, so a
* link decodes the same whenever it is opened.
*/
const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' });
const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' });
function imageryKey(product, year, month, day) {
const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
const iso = Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: '';
return product && iso === `${year}-${month}-${day}`
? `${product}:${iso}`
: null;
}
function imageryPinOption(key, token) {
return Object.freeze({
key,
token,
defaultValue: null,
normalize: (value) => {
const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec(
typeof value === 'string' ? value.trim() : '',
);
return match ? imageryKey(match[1], match[2], match[3], match[4]) : null;
},
encode: (value) => {
const [product, day] = value.split(':');
return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`;
},
decode: (value) => {
const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec(
typeof value === 'string' ? value : '',
);
return match
? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4])
: null;
},
});
}
const OPTION_GROUPS = Object.freeze({
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
@@ -303,6 +381,23 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('showProjection', 'p', true),
booleanOption('autoHop', 'a', false),
]),
'recent-imagery': Object.freeze([
// Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit.
boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }),
boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }),
imageryPinOption('a', 'a'),
imageryPinOption('b', 'b'),
// 0 one image, 1 image against the basemap, 2 two images A / B.
boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }),
// The swipe position is one comparison's framing, not a preference: a
// stored split resurfaced in the next session's first comparison.
shareOnlyOption(
boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }),
),
booleanOption('viirs', 'v', false),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -420,6 +515,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'radio',
}),
Object.freeze({
id: 'recent-imagery',
token: '1',
disposition: 'enabled+options',
optionOwner: 'recent-imagery',
}),
Object.freeze({
id: 'rocket-launches',
token: 'x',
@@ -699,13 +800,28 @@ export function decodeLayerStateParams(params) {
return normalizeLayerState({ enabledLayerIds, options: rawOptions });
}
/** Stable local-storage representation (full IDs for debuggability). */
/** Options with share-link-only values reset to their defaults. */
function withoutShareOnlyOptions(options) {
const out = {};
for (const ownerId of OPTION_OWNER_IDS) {
out[ownerId] = { ...(options?.[ownerId] || {}) };
for (const spec of optionSpecs(ownerId)) {
if (spec.shareOnly) out[ownerId][spec.key] = spec.defaultValue;
}
}
return out;
}
/**
* Stable local-storage representation (full IDs for debuggability), with
* share-link-only options at their defaults.
*/
export function serializeStoredLayerState(state) {
const normalized = normalizeLayerState(state);
return JSON.stringify({
v: LAYER_STATE_VERSION,
l: normalized.enabledLayerIds,
o: normalized.options,
o: withoutShareOnlyOptions(normalized.options),
});
}
@@ -717,7 +833,7 @@ export function parseStoredLayerState(raw) {
return null;
return normalizeLayerState({
enabledLayerIds: parsed.l,
options: parsed.o,
options: withoutShareOnlyOptions(parsed.o),
});
} catch {
return null;
+1040 -284
View File
File diff suppressed because it is too large. Load diff