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
+32
-136
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user