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');
}