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
@@ -13,6 +13,7 @@ import { createApplicationRadio } from './layers/radio.js';
|
||||
import { createApplicationTraffic } from './layers/traffic.js';
|
||||
import { createApplicationBikeshare } from './layers/bikeshare.js';
|
||||
import { createApplicationDirections } from './layers/directions.js';
|
||||
import { createApplicationRecentImagery } from './layers/recentImagery.js';
|
||||
import { createApplicationTransit } from './layers/transit.js';
|
||||
import { createApplicationInstallations } from './layers/militaryInstallations.js';
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
@@ -129,6 +130,7 @@ export function createApplicationCatalog({
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationRecentImagery(),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
|
||||
@@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 26);
|
||||
assert.equal(first.layers.length, 27);
|
||||
assert.notEqual(first.weatherClock, second.weatherClock);
|
||||
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
|
||||
assert.match(
|
||||
|
||||
@@ -70,6 +70,12 @@ export class LayerPresentation {
|
||||
mount(container) {
|
||||
this.panel.mount(container);
|
||||
}
|
||||
/** Hand the Recent Imagery readout factory to the panel (see LayerPanel). */
|
||||
attachRecentImagery(factory) {
|
||||
// Detaching never builds a panel that teardown already released.
|
||||
if (factory) this.panel.attachRecentImagery(factory);
|
||||
else this._panel?.attachRecentImagery(null);
|
||||
}
|
||||
refresh() {
|
||||
this._panel?._refreshTogglePanel();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct the Recent Imagery layer with the real GIBS renderer and the
|
||||
* browser thumbnail loader. It takes no data source: the catalog is NASA
|
||||
* CMR and the tiles are GIBS, both browser-direct and keyless.
|
||||
* @returns {object} A fresh layer instance for this catalog.
|
||||
*/
|
||||
export function createApplicationRecentImagery() {
|
||||
return createRecentImageryLayer({
|
||||
renderer: createRecentImageryRenderer({
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
thumbnails: createThumbnailLoader(),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
@@ -53,6 +55,43 @@ export function createApplicationTools({
|
||||
// lifetime rather than to whoever last pressed the button.
|
||||
const drawTool = initDrawTool({ viewer, annotations });
|
||||
defer(() => drawTool?.destroy());
|
||||
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
|
||||
// panel lives on the right rail, so both belong to the application
|
||||
// lifetime. The tileset lets the layer drape while the globe is hidden.
|
||||
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
|
||||
if (recentImagery) {
|
||||
recentImagery.attachTileset(tileset);
|
||||
const imageryBoxTool = initImageryBoxTool({
|
||||
viewer,
|
||||
onBox: (box) => recentImagery.setBox(box),
|
||||
onCancel: (reason, message, box) => {
|
||||
if (message) recentImagery.reportBoxRefusal(message, box);
|
||||
},
|
||||
onActive: (active) => recentImagery.setToolActive(active),
|
||||
// The tool takes Escape in a capture listener, so the panel's order
|
||||
// (clear a preview before cancelling the tool) is applied here.
|
||||
onEscape: () => recentImagery.clearPreview(),
|
||||
});
|
||||
// The readout mounts in its rail body through the layer panel, like the
|
||||
// weather readout.
|
||||
data.presentation.attachRecentImagery((container) =>
|
||||
createRecentImageryPanel({
|
||||
container,
|
||||
viewer,
|
||||
layer: recentImagery,
|
||||
tool: imageryBoxTool,
|
||||
}),
|
||||
);
|
||||
// The live gate's handle (scripts/qa-recent-imagery.mjs).
|
||||
const recentImageryHandle = { layer: recentImagery, tool: imageryBoxTool };
|
||||
window.__gevRecentImagery = recentImageryHandle;
|
||||
defer(() => {
|
||||
if (window.__gevRecentImagery === recentImageryHandle)
|
||||
delete window.__gevRecentImagery;
|
||||
data.presentation.attachRecentImagery(null);
|
||||
imageryBoxTool?.destroy();
|
||||
});
|
||||
}
|
||||
if (startChrome)
|
||||
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
|
||||
+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
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* NASA CMR granule catalog for the HLS products, folded with the VIIRS
|
||||
* overview days into one candidate list.
|
||||
*/
|
||||
import {
|
||||
CATALOG_DAYS,
|
||||
PRODUCTS,
|
||||
coverageFor,
|
||||
groupGranulesByDay,
|
||||
mergeCandidates,
|
||||
utcDay,
|
||||
validateBox,
|
||||
viirsCandidates,
|
||||
} from './model.js';
|
||||
|
||||
const CMR_ENDPOINT = 'https://cmr.earthdata.nasa.gov/search';
|
||||
const DAY_MS = 86_400_000;
|
||||
const CMR_PAGE_SIZE = 200;
|
||||
/**
|
||||
* Most records read per collection. A 1,000 km box can touch hundreds of HLS
|
||||
* tiles a day; past this cap the newest days (the sort order) are kept and
|
||||
* the result is `truncated`.
|
||||
*/
|
||||
const CMR_MAX_RECORDS = 2000;
|
||||
const CMR_SEARCH_AFTER = 'CMR-Search-After';
|
||||
|
||||
/**
|
||||
* Read one product's granules across CMR pages, sending back each response's
|
||||
* `CMR-Search-After` cursor until a short page, a missing cursor or the
|
||||
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
|
||||
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
|
||||
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
|
||||
*/
|
||||
export async function fetchCmrPages({
|
||||
product,
|
||||
url,
|
||||
fetchImpl,
|
||||
signal,
|
||||
maxRecords = CMR_MAX_RECORDS,
|
||||
}) {
|
||||
const granules = [];
|
||||
let hits = 0;
|
||||
let cursor = null;
|
||||
for (;;) {
|
||||
const init = { signal };
|
||||
if (cursor) init.headers = { [CMR_SEARCH_AFTER]: cursor };
|
||||
const response = await fetchImpl(url, init);
|
||||
if (!response?.ok)
|
||||
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
|
||||
const page = parseCmrUmm(await response.json(), product);
|
||||
granules.push(...page.granules);
|
||||
hits = Math.max(hits, page.hits);
|
||||
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
|
||||
if (
|
||||
!cursor ||
|
||||
page.granules.length < CMR_PAGE_SIZE ||
|
||||
granules.length >= maxRecords ||
|
||||
granules.length >= hits
|
||||
)
|
||||
break;
|
||||
}
|
||||
return { granules, truncated: hits > granules.length };
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function isoOrNull(value) {
|
||||
return typeof value === 'string' && Number.isFinite(Date.parse(value))
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function ringOf(points) {
|
||||
const ring = points
|
||||
.map(([lon, lat]) => [finiteOrNull(lon), finiteOrNull(lat)])
|
||||
.filter(([lon, lat]) => lon !== null && lat !== null);
|
||||
return ring.length >= 3 ? ring : null;
|
||||
}
|
||||
|
||||
function footprintOf(geometry) {
|
||||
const polygon = geometry?.GPolygons?.[0]?.Boundary?.Points;
|
||||
if (Array.isArray(polygon)) {
|
||||
const ring = ringOf(polygon.map((p) => [p?.Longitude, p?.Latitude]));
|
||||
if (ring) return ring;
|
||||
}
|
||||
const r = geometry?.BoundingRectangles?.[0];
|
||||
if (!r) return null;
|
||||
const [w, s, e, n] = [
|
||||
r.WestBoundingCoordinate,
|
||||
r.SouthBoundingCoordinate,
|
||||
r.EastBoundingCoordinate,
|
||||
r.NorthBoundingCoordinate,
|
||||
];
|
||||
const ring = ringOf([
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
]);
|
||||
return ring?.length === 4 ? ring : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CMR `granules.umm_json` response. Every field is optional upstream;
|
||||
* granules without a parseable start time are dropped.
|
||||
* @param {object} json
|
||||
* @param {string} product `S30` | `L30`
|
||||
* @returns {{ granules: Array<object>, hits: number }}
|
||||
*/
|
||||
export function parseCmrUmm(json, product) {
|
||||
const granules = [];
|
||||
for (const item of Array.isArray(json?.items) ? json.items : []) {
|
||||
const umm = item?.umm || {};
|
||||
const meta = item?.meta || {};
|
||||
const range = umm.TemporalExtent?.RangeDateTime || {};
|
||||
const timeStart = isoOrNull(range.BeginningDateTime);
|
||||
if (!timeStart) continue;
|
||||
const cloud = Array.isArray(umm.AdditionalAttributes)
|
||||
? umm.AdditionalAttributes.find((a) => a?.Name === 'CLOUD_COVERAGE')
|
||||
?.Values?.[0]
|
||||
: null;
|
||||
granules.push({
|
||||
id: String(
|
||||
meta['concept-id'] || umm.GranuleUR || meta['native-id'] || '',
|
||||
),
|
||||
product,
|
||||
timeStart,
|
||||
timeEnd: isoOrNull(range.EndingDateTime) || timeStart,
|
||||
cloud: finiteOrNull(cloud),
|
||||
footprint: footprintOf(
|
||||
umm.SpatialExtent?.HorizontalSpatialDomain?.Geometry,
|
||||
),
|
||||
});
|
||||
}
|
||||
return { granules, hits: finiteOrNull(json?.hits) ?? granules.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* CMR granule search URL for one HLS product over a box and time window.
|
||||
* @param {{ product: string, box: object, startIso: string, endIso: string }} query
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cmrSearchUrl({ product, box, startIso, endIso }) {
|
||||
const spec = PRODUCTS[product];
|
||||
if (!spec?.cmrCollection)
|
||||
throw new TypeError(`No CMR collection for product: ${product}`);
|
||||
const result = validateBox(box);
|
||||
if (!result.ok) throw new TypeError(result.message);
|
||||
const { west, south, east, north } = result.box;
|
||||
return (
|
||||
`${CMR_ENDPOINT}/granules.umm_json` +
|
||||
`?collection_concept_id=${spec.cmrCollection}` +
|
||||
`&bounding_box=${west},${south},${east},${north}` +
|
||||
`&temporal=${startIso},${endIso}` +
|
||||
`&sort_key=-start_date&page_size=${CMR_PAGE_SIZE}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search both HLS collections concurrently and fold the results with the
|
||||
* VIIRS overview days. One product failing still yields the other's days plus
|
||||
* an `errors` entry; an invalid box or clock throws a TypeError.
|
||||
* @param {{ box: object, days?: number, now?: Date, fetchImpl?: typeof fetch, signal?: AbortSignal }} request
|
||||
* @returns {Promise<{ candidates: Array<object>, truncated: boolean, errors: Array<{ product: string, message: string }> }>}
|
||||
*/
|
||||
export async function searchHls({
|
||||
box,
|
||||
days = CATALOG_DAYS,
|
||||
now = new Date(),
|
||||
fetchImpl = globalThis.fetch,
|
||||
signal,
|
||||
} = {}) {
|
||||
const validated = validateBox(box);
|
||||
if (!validated.ok) throw new TypeError(validated.message);
|
||||
if (typeof fetchImpl !== 'function')
|
||||
throw new TypeError('A fetch implementation is required');
|
||||
const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
|
||||
if (!Number.isFinite(nowMs)) throw new TypeError('A valid clock is required');
|
||||
const endIso = new Date(nowMs).toISOString();
|
||||
const startIso = `${utcDay(new Date(nowMs - (days - 1) * DAY_MS))}T00:00:00Z`;
|
||||
const products = ['S30', 'L30'];
|
||||
const settled = await Promise.allSettled(
|
||||
products.map((product) =>
|
||||
fetchCmrPages({
|
||||
product,
|
||||
url: cmrSearchUrl({ product, box: validated.box, startIso, endIso }),
|
||||
fetchImpl,
|
||||
signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const granules = [];
|
||||
const errors = [];
|
||||
let truncated = false;
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
granules.push(...result.value.granules);
|
||||
truncated ||= result.value.truncated;
|
||||
} else {
|
||||
errors.push({
|
||||
product: products[index],
|
||||
message: String(result.reason?.message || result.reason),
|
||||
});
|
||||
}
|
||||
});
|
||||
const hls = groupGranulesByDay(granules).map((candidate) => ({
|
||||
...candidate,
|
||||
coverage: coverageFor(candidate, validated.box),
|
||||
}));
|
||||
return {
|
||||
candidates: mergeCandidates([hls, viirsCandidates(endIso, days)]),
|
||||
truncated,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
cmrSearchUrl,
|
||||
fetchCmrPages,
|
||||
parseCmrUmm,
|
||||
searchHls,
|
||||
} from './catalog.js';
|
||||
import { BOX, response } from './testDoubles.mjs';
|
||||
|
||||
function ummItem({
|
||||
conceptId = 'G1-LPCLOUD',
|
||||
begin = '2026-09-18T17:12:01.000Z',
|
||||
end = '2026-09-18T17:12:31.000Z',
|
||||
cloud = '12',
|
||||
polygon = [
|
||||
[-98, 30],
|
||||
[-97, 30],
|
||||
[-97, 31],
|
||||
[-98, 31],
|
||||
],
|
||||
rectangle = null,
|
||||
} = {}) {
|
||||
const Geometry = {};
|
||||
if (polygon)
|
||||
Geometry.GPolygons = [
|
||||
{
|
||||
Boundary: {
|
||||
Points: polygon.map(([Longitude, Latitude]) => ({
|
||||
Longitude,
|
||||
Latitude,
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
if (rectangle)
|
||||
Geometry.BoundingRectangles = [
|
||||
{
|
||||
WestBoundingCoordinate: rectangle[0],
|
||||
SouthBoundingCoordinate: rectangle[1],
|
||||
EastBoundingCoordinate: rectangle[2],
|
||||
NorthBoundingCoordinate: rectangle[3],
|
||||
},
|
||||
];
|
||||
return {
|
||||
meta: { 'concept-id': conceptId },
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: begin, EndingDateTime: end },
|
||||
},
|
||||
AdditionalAttributes: [
|
||||
{ Name: 'SPATIAL_COVERAGE', Values: ['100'] },
|
||||
...(cloud === undefined
|
||||
? []
|
||||
: [{ Name: 'CLOUD_COVERAGE', Values: [cloud] }]),
|
||||
],
|
||||
SpatialExtent: { HorizontalSpatialDomain: { Geometry } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('umm_json granules carry id, times, scene cloud and a footprint ring', () => {
|
||||
const { granules, hits } = parseCmrUmm(
|
||||
{
|
||||
hits: 9,
|
||||
items: [
|
||||
ummItem(),
|
||||
ummItem({
|
||||
conceptId: 'G2',
|
||||
cloud: '3.5',
|
||||
polygon: null,
|
||||
rectangle: [-98.5, 29.5, -97.5, 30.5],
|
||||
}),
|
||||
{
|
||||
meta: {},
|
||||
umm: {
|
||||
GranuleUR: 'HLS.L30.only-ur',
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: '2026-09-01T16:00:00Z' },
|
||||
},
|
||||
},
|
||||
},
|
||||
ummItem({ conceptId: 'G-bad-cloud', cloud: 'n/a' }),
|
||||
ummItem({
|
||||
conceptId: 'G-two-points',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
}),
|
||||
{ meta: { 'concept-id': 'G-no-time' }, umm: {} },
|
||||
{
|
||||
meta: { 'concept-id': 'G-bad-time' },
|
||||
umm: {
|
||||
TemporalExtent: { RangeDateTime: { BeginningDateTime: 'soon' } },
|
||||
},
|
||||
},
|
||||
null,
|
||||
],
|
||||
},
|
||||
'S30',
|
||||
);
|
||||
assert.equal(hits, 9);
|
||||
assert.deepEqual(granules[0], {
|
||||
id: 'G1-LPCLOUD',
|
||||
product: 'S30',
|
||||
timeStart: '2026-09-18T17:12:01.000Z',
|
||||
timeEnd: '2026-09-18T17:12:31.000Z',
|
||||
cloud: 12,
|
||||
footprint: [
|
||||
[-98, 30],
|
||||
[-97, 30],
|
||||
[-97, 31],
|
||||
[-98, 31],
|
||||
],
|
||||
});
|
||||
assert.equal(granules[1].cloud, 3.5);
|
||||
assert.deepEqual(granules[1].footprint, [
|
||||
[-98.5, 29.5],
|
||||
[-97.5, 29.5],
|
||||
[-97.5, 30.5],
|
||||
[-98.5, 30.5],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
granules.slice(2).map((g) => [g.id, g.cloud, g.footprint]),
|
||||
[
|
||||
['HLS.L30.only-ur', null, null],
|
||||
['G-bad-cloud', null, granules[0].footprint],
|
||||
['G-two-points', 12, null],
|
||||
],
|
||||
'missing fields stay unknown; granules without a start time are dropped',
|
||||
);
|
||||
assert.equal(granules[2].timeEnd, '2026-09-01T16:00:00Z');
|
||||
assert.deepEqual(parseCmrUmm(null, 'S30'), { granules: [], hits: 0 });
|
||||
});
|
||||
|
||||
test('the CMR query names the collection, box, window and page size with literal commas', () => {
|
||||
assert.equal(
|
||||
cmrSearchUrl({
|
||||
product: 'S30',
|
||||
box: BOX,
|
||||
startIso: '2026-08-23T00:00:00Z',
|
||||
endIso: '2026-09-21T12:00:00.000Z',
|
||||
}),
|
||||
'https://cmr.earthdata.nasa.gov/search/granules.umm_json?collection_concept_id=C2021957295-LPCLOUD&bounding_box=-97.8,30.2,-97.7,30.3&temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00.000Z&sort_key=-start_date&page_size=200',
|
||||
);
|
||||
assert.match(
|
||||
cmrSearchUrl({ product: 'L30', box: BOX, startIso: 'a', endIso: 'b' }),
|
||||
/collection_concept_id=C2021957657-LPCLOUD/,
|
||||
);
|
||||
assert.throws(
|
||||
() => cmrSearchUrl({ product: 'VIIRS', box: BOX }),
|
||||
/No CMR collection/,
|
||||
);
|
||||
});
|
||||
|
||||
/** Route a CMR request to the S30 or L30 handler by collection id. */
|
||||
function cmrFetch(handlers, calls = []) {
|
||||
return async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
const handler = handlers[url.includes('C2021957295') ? 'S30' : 'L30'];
|
||||
return typeof handler === 'function'
|
||||
? handler()
|
||||
: response({ json: handler });
|
||||
};
|
||||
}
|
||||
|
||||
test('both HLS collections fold with the VIIRS days; one failing product is reported, not fatal', async () => {
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
const result = await searchHls({
|
||||
box: BOX,
|
||||
now: new Date('2026-09-21T12:00:00Z'),
|
||||
signal,
|
||||
fetchImpl: cmrFetch(
|
||||
{
|
||||
S30: {
|
||||
hits: 2,
|
||||
items: [
|
||||
ummItem(),
|
||||
ummItem({ conceptId: 'G-old', begin: '2026-09-08T17:12:01Z' }),
|
||||
],
|
||||
},
|
||||
L30: () => response({ ok: false, status: 503 }),
|
||||
},
|
||||
calls,
|
||||
),
|
||||
});
|
||||
assert.deepEqual(result.errors, [
|
||||
{ product: 'L30', message: 'HTTP 503 from CMR' },
|
||||
]);
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(calls[0].init.signal, signal);
|
||||
assert.match(
|
||||
calls[0].url,
|
||||
/temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00\.000Z/,
|
||||
'30 UTC days ending now',
|
||||
);
|
||||
const keys = result.candidates.map((c) => c.key);
|
||||
assert.equal(keys.length, 32);
|
||||
assert.deepEqual(keys.slice(2, 5), [
|
||||
'VIIRS:2026-09-19',
|
||||
'S30:2026-09-18',
|
||||
'VIIRS:2026-09-18',
|
||||
]);
|
||||
assert.ok(keys.includes('S30:2026-09-08'));
|
||||
assert.equal(
|
||||
result.candidates.find((c) => c.key === 'S30:2026-09-18').coverage,
|
||||
'full',
|
||||
'coverage is computed against the box',
|
||||
);
|
||||
|
||||
const thrown = await searchHls({
|
||||
box: BOX,
|
||||
days: 2,
|
||||
now: '2026-09-21T00:00:00Z',
|
||||
fetchImpl: cmrFetch({
|
||||
S30: () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
L30: { hits: 500, items: [ummItem({ conceptId: 'G-L30' })] },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(thrown.errors, [
|
||||
{ product: 'S30', message: 'network down' },
|
||||
]);
|
||||
assert.equal(thrown.truncated, true, 'CMR has more hits than were read');
|
||||
assert.deepEqual(
|
||||
thrown.candidates.map((c) => c.key),
|
||||
['VIIRS:2026-09-21', 'VIIRS:2026-09-20', 'L30:2026-09-18'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an invalid box or clock is refused before any request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = async () => {
|
||||
called = true;
|
||||
};
|
||||
await assert.rejects(
|
||||
searchHls({
|
||||
box: { west: 179, south: 0, east: -179, north: 1 },
|
||||
fetchImpl,
|
||||
}),
|
||||
/dateline/,
|
||||
);
|
||||
await assert.rejects(
|
||||
searchHls({ box: BOX, now: 'never', fetchImpl }),
|
||||
/clock/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('CMR pages follow the CMR-Search-After cursor up to 2,000 records and truncation stays honest', async () => {
|
||||
const items = (count) =>
|
||||
Array.from({ length: count }, (_, i) => ummItem({ conceptId: `G-${i}` }));
|
||||
const cursors = [];
|
||||
const paged = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'https://cmr/x',
|
||||
fetchImpl: async (url, init) => {
|
||||
const page = cursors.push(init.headers?.['CMR-Search-After'] ?? null);
|
||||
return response({
|
||||
headers: { 'CMR-Search-After': `cursor-${page}` },
|
||||
json: { hits: 450, items: items(page < 3 ? 200 : 50) },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(paged.granules.length, 450);
|
||||
assert.equal(paged.truncated, false);
|
||||
assert.deepEqual(cursors, [null, 'cursor-1', 'cursor-2']);
|
||||
|
||||
let pages = 0;
|
||||
const capped = await fetchCmrPages({
|
||||
product: 'L30',
|
||||
url: 'https://cmr/y',
|
||||
fetchImpl: async () => {
|
||||
pages += 1;
|
||||
return response({
|
||||
headers: { 'cmr-search-after': 'more' },
|
||||
json: { hits: 5000, items: items(200) },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(pages, 10);
|
||||
assert.equal(capped.granules.length, 2000);
|
||||
assert.equal(capped.truncated, true);
|
||||
|
||||
const noCursor = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'u',
|
||||
fetchImpl: async () => response({ json: { hits: 900, items: items(200) } }),
|
||||
});
|
||||
assert.equal(noCursor.granules.length, 200);
|
||||
assert.equal(noCursor.truncated, true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Pure domain model for the Recent Imagery layer: products, the selection box,
|
||||
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
|
||||
* no network.
|
||||
*/
|
||||
|
||||
/** How far back the catalog looks, in UTC days. */
|
||||
export const CATALOG_DAYS = 30;
|
||||
|
||||
const MAX_BOX_SIDE_KM = 1000;
|
||||
/** ZOOM IN lands where the top-down view is this wide, well inside the cap. */
|
||||
const FIT_VIEW_KM = 400;
|
||||
const FIT_HEIGHT_KM = Object.freeze({ min: 5, max: 400 });
|
||||
/** GIBS EPSG:3857 tiles do not exist beyond the Web-Mercator limit. */
|
||||
const MERCATOR_LAT_LIMIT = 85.0511;
|
||||
const MAX_CLOUD_FOR_CLEAR = 20;
|
||||
const PIN_BOX_SIDE_KM = 10;
|
||||
const WVS_ENDPOINT = 'https://wvs.earthdata.nasa.gov/api/v1/snapshot';
|
||||
const KM_PER_DEG_LAT = 111.32;
|
||||
const QUANTUM = 100000;
|
||||
const DAY_MS = 86_400_000;
|
||||
const PRODUCT_ORDER = ['S30', 'L30', 'VIIRS'];
|
||||
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
|
||||
|
||||
/**
|
||||
* Keyless, browser-direct imagery products. HLS products are searched via
|
||||
* CMR; the VIIRS overview is a daily global mosaic with no granule catalog.
|
||||
* `source` is the on/off group the row chips toggle.
|
||||
*/
|
||||
export const PRODUCTS = Object.freeze({
|
||||
S30: Object.freeze({
|
||||
label: 'HLS S30',
|
||||
name: 'Sentinel-2',
|
||||
source: 'hls',
|
||||
sensor: 'Sentinel-2 via HLS',
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
format: 'png',
|
||||
cmrCollection: 'C2021957295-LPCLOUD',
|
||||
overview: false,
|
||||
}),
|
||||
L30: Object.freeze({
|
||||
label: 'HLS L30',
|
||||
name: 'Landsat 8/9',
|
||||
source: 'hls',
|
||||
sensor: 'Landsat 8/9 via HLS',
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
format: 'png',
|
||||
cmrCollection: 'C2021957657-LPCLOUD',
|
||||
overview: false,
|
||||
}),
|
||||
VIIRS: Object.freeze({
|
||||
label: 'VIIRS',
|
||||
name: 'Daily overview',
|
||||
source: 'viirs',
|
||||
sensor: 'VIIRS NOAA-21',
|
||||
resolutionM: 250,
|
||||
gibsLayer: 'VIIRS_NOAA21_CorrectedReflectance_TrueColor',
|
||||
maxLevel: 9,
|
||||
format: 'jpg',
|
||||
cmrCollection: null,
|
||||
overview: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
const toRadians = (degrees) => (degrees * Math.PI) / 180;
|
||||
const pad2 = (value) => String(value).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* Coerce a box to `{ west, south, east, north }` with south ≤ north. West and
|
||||
* east are not swapped: west > east crosses the dateline and `validateBox`
|
||||
* rejects it. Null when any edge is nonfinite.
|
||||
*/
|
||||
function normalizeBox(box) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
const [west, south, east, north] = [
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
].map(Number);
|
||||
if (![west, south, east, north].every(Number.isFinite)) return null;
|
||||
return {
|
||||
west,
|
||||
south: Math.min(south, north),
|
||||
east,
|
||||
north: Math.max(south, north),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Side lengths of a box in km: the box's own longitude span (never the
|
||||
* shorter way round the globe) at its mid-latitude, and its latitude span.
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {{ width: number, height: number }}
|
||||
*/
|
||||
export function boxSideKm(box) {
|
||||
const normalized = normalizeBox(box);
|
||||
if (!normalized) return { width: NaN, height: NaN };
|
||||
const midLat = (normalized.south + normalized.north) / 2;
|
||||
return {
|
||||
width:
|
||||
(normalized.east - normalized.west) *
|
||||
KM_PER_DEG_LAT *
|
||||
Math.cos(toRadians(midLat)),
|
||||
height: (normalized.north - normalized.south) * KM_PER_DEG_LAT,
|
||||
};
|
||||
}
|
||||
|
||||
const refuse = (reason, message) => ({ ok: false, reason, message });
|
||||
|
||||
/**
|
||||
* Validate a selection box for the catalog and the tile providers.
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {{ ok: true, box: object } | { ok: false, reason: 'dateline' | 'polar' | 'degenerate' | 'too-large' | 'invalid', message: string }}
|
||||
*/
|
||||
export function validateBox(box) {
|
||||
const normalized = normalizeBox(box);
|
||||
if (!normalized) return refuse('invalid', 'Box edges must be finite');
|
||||
const { west, south, east, north } = normalized;
|
||||
if (Math.abs(west) > 180 || Math.abs(east) > 180)
|
||||
return refuse('invalid', 'Longitudes must be within ±180°');
|
||||
if (west > east) return refuse('dateline', 'Select one side of the dateline');
|
||||
if (
|
||||
Math.abs(south) > MERCATOR_LAT_LIMIT ||
|
||||
Math.abs(north) > MERCATOR_LAT_LIMIT
|
||||
)
|
||||
return refuse(
|
||||
'polar',
|
||||
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
|
||||
);
|
||||
if (east === west || north === south)
|
||||
return refuse('degenerate', 'Box must enclose an area');
|
||||
const { width, height } = boxSideKm(normalized);
|
||||
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
|
||||
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
|
||||
return { ok: true, box: normalized };
|
||||
}
|
||||
|
||||
const formatKm = (km) => Math.round(Number(km) || 0).toLocaleString('en-US');
|
||||
|
||||
// One short line each: the panel's hint says what to do about it.
|
||||
function tooLargeMessage(sideKm) {
|
||||
return `Box is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
|
||||
}
|
||||
|
||||
/** The refusal for USE VIEW when the camera sees more than the cap allows. */
|
||||
export function viewTooLargeMessage(sideKm) {
|
||||
return `View is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera height, in metres, at which a top-down view is `FIT_VIEW_KM`
|
||||
* wide: the horizontal half-angle is atan(tan(fovy / 2) · width / height).
|
||||
* Clamped to 5–400 km; without a canvas or FOV, a square 60° view.
|
||||
* @param {{ width?: number, height?: number, fovy?: number }} view Canvas size in px, vertical FOV in radians.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function fitViewHeightM({ width, height, fovy } = {}) {
|
||||
const aspect = Number(width) / Number(height);
|
||||
const valid =
|
||||
Number.isFinite(aspect) && aspect > 0 && fovy > 0 && fovy < Math.PI;
|
||||
const halfWidth = valid ? Math.tan(fovy / 2) * aspect : Math.tan(Math.PI / 6);
|
||||
const km = FIT_VIEW_KM / (2 * halfWidth);
|
||||
return Math.min(FIT_HEIGHT_KM.max, Math.max(FIT_HEIGHT_KM.min, km)) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The centre of a box that does not cross the dateline, in degrees.
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {{ lon: number, lat: number } | null}
|
||||
*/
|
||||
export function boxCentre(box) {
|
||||
const normalized = normalizeBox(box);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
lon: (normalized.west + normalized.east) / 2,
|
||||
lat: (normalized.south + normalized.north) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A square box centred on a pin, `sideKm` on the ground (the east–west extent
|
||||
* widens by 1/cos(lat)); null when the result would not validate.
|
||||
* @param {number} lon Degrees.
|
||||
* @param {number} lat Degrees.
|
||||
* @param {number} [sideKm]
|
||||
* @returns {object | null}
|
||||
*/
|
||||
export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
|
||||
if (!finite(lon) || !finite(lat) || !finite(sideKm) || sideKm <= 0)
|
||||
return null;
|
||||
const cosLat = Math.cos(toRadians(lat));
|
||||
if (cosLat <= 0) return null;
|
||||
const halfLat = sideKm / 2 / KM_PER_DEG_LAT;
|
||||
const halfLon = sideKm / 2 / (KM_PER_DEG_LAT * cosLat);
|
||||
const clampLat = (value) =>
|
||||
Math.max(-MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, value));
|
||||
const result = validateBox({
|
||||
west: lon - halfLon,
|
||||
south: clampLat(lat - halfLat),
|
||||
east: lon + halfLon,
|
||||
north: clampLat(lat + halfLat),
|
||||
});
|
||||
return result.ok ? result.box : null;
|
||||
}
|
||||
|
||||
function mapBox(box, fn) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
return normalizeBox({
|
||||
west: fn(box.west),
|
||||
south: fn(box.south),
|
||||
east: fn(box.east),
|
||||
north: fn(box.north),
|
||||
});
|
||||
}
|
||||
|
||||
/** Degrees box from a radians rectangle (Cesium `Rectangle` shape). */
|
||||
export function boxFromRectangle(rectangle) {
|
||||
return mapBox(rectangle, (radians) => (Number(radians) * 180) / Math.PI);
|
||||
}
|
||||
|
||||
/** Degrees box → signed integers at degrees × 100000 (the share-link form). */
|
||||
export function quantizeBox(box) {
|
||||
return mapBox(normalizeBox(box), (value) => Math.round(value * QUANTUM));
|
||||
}
|
||||
|
||||
/** Inverse of `quantizeBox`. */
|
||||
export function dequantizeBox(ints) {
|
||||
return mapBox(ints, (value) => Number(value) / QUANTUM);
|
||||
}
|
||||
|
||||
function isValidDay(day) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day));
|
||||
if (!match) return false;
|
||||
const time = Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
);
|
||||
return (
|
||||
Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC calendar day (`YYYY-MM-DD`) of an ISO timestamp, Date or day string;
|
||||
* null when unparseable or not a real day.
|
||||
* @param {string | number | Date} value
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function utcDay(value) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
|
||||
return isValidDay(value) ? value : null;
|
||||
const time = value instanceof Date ? value.getTime() : Date.parse(value);
|
||||
return Number.isFinite(time)
|
||||
? new Date(time).toISOString().slice(0, 10)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
|
||||
export function shortDay(day) {
|
||||
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
|
||||
return match ? `${MONTHS[Number(match[1]) - 1]} ${Number(match[2])}` : '';
|
||||
}
|
||||
|
||||
/** `17:12` (UTC) for an ISO timestamp; '' when unparseable. */
|
||||
export function hhmm(iso) {
|
||||
const time = Date.parse(iso);
|
||||
if (!Number.isFinite(time)) return '';
|
||||
const date = new Date(time);
|
||||
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
|
||||
export function candidateKey(product, day) {
|
||||
return `${product}:${day}`;
|
||||
}
|
||||
|
||||
/** Split a candidate key; null when the product is unknown or the day invalid. */
|
||||
export function parseCandidateKey(key) {
|
||||
const match = /^(S30|L30|VIIRS):(\d{4}-\d{2}-\d{2})$/.exec(String(key));
|
||||
if (!match || !isValidDay(match[2])) return null;
|
||||
return { product: match[1], day: match[2] };
|
||||
}
|
||||
|
||||
const cloudOf = (granule) => (finite(granule?.cloud) ? granule.cloud : null);
|
||||
|
||||
function buildCandidate(product, day, granules) {
|
||||
const clouds = granules.map(cloudOf).filter((value) => value !== null);
|
||||
const starts = granules
|
||||
.map((granule) => Date.parse(granule.timeStart))
|
||||
.filter(Number.isFinite);
|
||||
const ends = granules
|
||||
.map((granule) => Date.parse(granule.timeEnd ?? granule.timeStart))
|
||||
.filter(Number.isFinite);
|
||||
return {
|
||||
key: candidateKey(product, day),
|
||||
product,
|
||||
day,
|
||||
granules,
|
||||
cloud: clouds.length
|
||||
? { min: Math.min(...clouds), max: Math.max(...clouds) }
|
||||
: null,
|
||||
timeRange:
|
||||
starts.length && ends.length
|
||||
? {
|
||||
start: new Date(Math.min(...starts)).toISOString(),
|
||||
end: new Date(Math.max(...ends)).toISOString(),
|
||||
}
|
||||
: null,
|
||||
availability: granules.length ? 'present' : 'empty',
|
||||
coverage: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group granules into one candidate per product and UTC day of `timeStart`,
|
||||
* newest first. Granules without a known product or start time are dropped.
|
||||
* @param {Array<object>} granules
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function groupGranulesByDay(granules) {
|
||||
const buckets = new Map();
|
||||
for (const granule of Array.isArray(granules) ? granules : []) {
|
||||
const day = utcDay(granule?.timeStart);
|
||||
if (!PRODUCTS[granule?.product] || !day) continue;
|
||||
const key = candidateKey(granule.product, day);
|
||||
if (!buckets.has(key))
|
||||
buckets.set(key, { product: granule.product, day, granules: [] });
|
||||
buckets.get(key).granules.push(granule);
|
||||
}
|
||||
return mergeCandidates([
|
||||
[...buckets.values()].map(({ product, day, granules: list }) =>
|
||||
buildCandidate(product, day, list),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One VIIRS overview candidate per UTC day for the last `days` days, today
|
||||
* first. Availability stays unknown until the thumbnail probe answers;
|
||||
* coverage is full because the overview is a global mosaic.
|
||||
* @param {string | Date} todayIso
|
||||
* @param {number} [days]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function viirsCandidates(todayIso, days = CATALOG_DAYS) {
|
||||
const today = utcDay(todayIso);
|
||||
if (!today) return [];
|
||||
const start = Date.parse(`${today}T00:00:00Z`);
|
||||
const out = [];
|
||||
for (let i = 0; i < Math.max(0, Math.trunc(days)); i += 1) {
|
||||
const day = new Date(start - i * DAY_MS).toISOString().slice(0, 10);
|
||||
out.push({
|
||||
key: candidateKey('VIIRS', day),
|
||||
product: 'VIIRS',
|
||||
day,
|
||||
granules: [],
|
||||
cloud: null,
|
||||
timeRange: null,
|
||||
availability: 'unknown',
|
||||
coverage: 'full',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge candidate lists newest day first, then S30, L30, VIIRS; the first
|
||||
* occurrence of a key wins.
|
||||
* @param {Array<Array<object>>} lists
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function mergeCandidates(lists) {
|
||||
const byKey = new Map();
|
||||
for (const list of lists) {
|
||||
for (const candidate of list || []) {
|
||||
if (candidate?.key && !byKey.has(candidate.key))
|
||||
byKey.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) =>
|
||||
a.day !== b.day
|
||||
? a.day < b.day
|
||||
? 1
|
||||
: -1
|
||||
: PRODUCT_ORDER.indexOf(a.product) - PRODUCT_ORDER.indexOf(b.product),
|
||||
);
|
||||
}
|
||||
|
||||
function pointInPolygon(lon, lat, ring) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
const crosses =
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi;
|
||||
if (crosses) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the box a candidate's granule footprints cover, sampled at the
|
||||
* four corners and the centre: `full`, `partial`, or `unknown` without
|
||||
* footprints.
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @returns {'full' | 'partial' | 'unknown'}
|
||||
*/
|
||||
export function coverageFor(candidate, box) {
|
||||
const normalized = normalizeBox(box);
|
||||
const footprints = (candidate?.granules || [])
|
||||
.map((granule) => granule?.footprint)
|
||||
.filter((ring) => Array.isArray(ring) && ring.length >= 3);
|
||||
if (!normalized || !footprints.length) return 'unknown';
|
||||
const { west, south, east, north } = normalized;
|
||||
const samples = [
|
||||
[west, south],
|
||||
[east, south],
|
||||
[east, north],
|
||||
[west, north],
|
||||
[(west + east) / 2, (south + north) / 2],
|
||||
];
|
||||
return samples.every(([lon, lat]) =>
|
||||
footprints.some((ring) => pointInPolygon(lon, lat, ring)),
|
||||
)
|
||||
? 'full'
|
||||
: 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the START HERE day. Coverage first: a present HLS day that covers the
|
||||
* whole box (`full`, or `unknown` without footprints) beats a newer sliver at
|
||||
* the box edge, which changes none of the frame when draped. Within a tier
|
||||
* the newest day whose granules ALL have known cloud ≤ `maxCloud` wins
|
||||
* (`clear`), else the newest (`cloudy`); only slivers left → `partial`; else
|
||||
* the newest non-empty VIIRS day (`overview`). `certain` is false when the
|
||||
* catalog was truncated.
|
||||
* @param {Array<object>} candidates
|
||||
* @param {{ maxCloud?: number, truncated?: boolean }} [options]
|
||||
* @returns {{ candidate: object | null, reason: 'clear' | 'cloudy' | 'partial' | 'overview' | null, certain: boolean }}
|
||||
*/
|
||||
export function rankLatest(
|
||||
candidates,
|
||||
{ maxCloud = MAX_CLOUD_FOR_CLEAR, truncated = false } = {},
|
||||
) {
|
||||
const sorted = mergeCandidates([candidates]);
|
||||
const certain = !truncated;
|
||||
const present = (c) => c.availability !== 'empty';
|
||||
const hlsDay = (c) =>
|
||||
PRODUCTS[c.product] &&
|
||||
!PRODUCTS[c.product].overview &&
|
||||
present(c) &&
|
||||
c.granules.length > 0;
|
||||
const coversBox = (c) => c.coverage !== 'partial';
|
||||
const clearSky = (c) =>
|
||||
c.granules.every((granule) => {
|
||||
const cloud = cloudOf(granule);
|
||||
return cloud !== null && cloud <= maxCloud;
|
||||
});
|
||||
const tiers = [
|
||||
[(c) => hlsDay(c) && coversBox(c) && clearSky(c), 'clear'],
|
||||
[(c) => hlsDay(c) && coversBox(c), 'cloudy'],
|
||||
[(c) => hlsDay(c) && clearSky(c), 'partial'],
|
||||
[hlsDay, 'partial'],
|
||||
[(c) => c.product === 'VIIRS' && present(c), 'overview'],
|
||||
];
|
||||
for (const [matches, reason] of tiers) {
|
||||
const candidate = sorted.find(matches);
|
||||
if (candidate) return { candidate, reason, certain };
|
||||
}
|
||||
return { candidate: null, reason: null, certain };
|
||||
}
|
||||
|
||||
function daysAgoLabel(day, now) {
|
||||
const today = utcDay(now);
|
||||
if (!today) return null;
|
||||
const delta = Math.round(
|
||||
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
|
||||
DAY_MS,
|
||||
);
|
||||
if (delta <= 0) return 'today';
|
||||
return delta === 1 ? 'yesterday' : `${delta} days ago`;
|
||||
}
|
||||
|
||||
function cloudLabel(cloud) {
|
||||
if (!cloud) return 'cloud unknown';
|
||||
const min = Math.round(cloud.min);
|
||||
const max = Math.round(cloud.max);
|
||||
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line UTC readout, e.g.
|
||||
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
|
||||
* @param {object} candidate
|
||||
* @param {string | number | Date} [now]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCandidateReadout(candidate, now = new Date()) {
|
||||
const product = PRODUCTS[candidate?.product];
|
||||
if (!product || !candidate?.day) return '';
|
||||
const [y, m, d] = candidate.day.split('-').map(Number);
|
||||
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
|
||||
if (candidate.timeRange?.start) {
|
||||
const start = hhmm(candidate.timeRange.start);
|
||||
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
|
||||
when +=
|
||||
(candidate.granules?.length || 0) > 1 && end !== start
|
||||
? ` ${start}–${end}Z`
|
||||
: ` ${start}Z`;
|
||||
}
|
||||
const parts = [when];
|
||||
const ago = daysAgoLabel(candidate.day, now);
|
||||
if (ago) parts.push(ago);
|
||||
parts.push(product.sensor, `${product.resolutionM} m`);
|
||||
if (product.overview) parts.push('overview');
|
||||
parts.push(cloudLabel(candidate.cloud));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* GIBS WMTS REST template with `{s}`, `{z}`, `{y}`, `{x}` placeholders (y
|
||||
* precedes x in the GIBS path).
|
||||
* @param {string} product
|
||||
* @param {string} day `YYYY-MM-DD`
|
||||
* @returns {string}
|
||||
*/
|
||||
export function gibsTemplate(product, day) {
|
||||
const spec = PRODUCTS[product];
|
||||
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
|
||||
return `https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/${spec.gibsLayer}/default/${day}/GoogleMapsCompatible_Level${spec.maxLevel}/{z}/{y}/{x}.${spec.format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worldview Snapshots URL for a thumbnail or export. EPSG:4326 BBOX order is
|
||||
* lat,lon: `south,west,north,east`.
|
||||
* @param {{ product: string, day: string, box: object, width: number, height: number, format?: string }} request
|
||||
* @returns {string}
|
||||
*/
|
||||
export function wvsSnapshotUrl({
|
||||
product,
|
||||
day,
|
||||
box,
|
||||
width,
|
||||
height,
|
||||
format = 'image/png',
|
||||
}) {
|
||||
const spec = PRODUCTS[product];
|
||||
const normalized = normalizeBox(box);
|
||||
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
|
||||
if (!normalized) throw new TypeError('A finite box is required');
|
||||
const bbox = [
|
||||
normalized.south,
|
||||
normalized.west,
|
||||
normalized.north,
|
||||
normalized.east,
|
||||
]
|
||||
.map((value) => String(Number(value.toFixed(6))))
|
||||
.join(',');
|
||||
return (
|
||||
`${WVS_ENDPOINT}?REQUEST=GetSnapshot&LAYERS=${spec.gibsLayer}` +
|
||||
`&CRS=EPSG:4326&TIME=${day}&BBOX=${bbox}` +
|
||||
`&WIDTH=${Math.round(width)}&HEIGHT=${Math.round(height)}&FORMAT=${format}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail load order: the focused card (clamped into the visible range),
|
||||
* the visible cards outward from it, then `extra` cards beyond each edge.
|
||||
* @param {number} focusIndex
|
||||
* @param {number} firstVisible
|
||||
* @param {number} lastVisible
|
||||
* @param {number} total
|
||||
* @param {number} [extra]
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function thumbnailOrder(
|
||||
focusIndex,
|
||||
firstVisible,
|
||||
lastVisible,
|
||||
total,
|
||||
extra = 2,
|
||||
) {
|
||||
const count = Math.max(0, Math.trunc(total));
|
||||
if (!count) return [];
|
||||
const clamp = (value) => Math.max(0, Math.min(count - 1, Math.trunc(value)));
|
||||
const first = clamp(Math.min(firstVisible, lastVisible));
|
||||
const last = clamp(Math.max(firstVisible, lastVisible));
|
||||
const focus = Math.max(first, Math.min(last, clamp(focusIndex)));
|
||||
const order = [];
|
||||
const push = (index) => {
|
||||
if (index >= 0 && index < count && !order.includes(index))
|
||||
order.push(index);
|
||||
};
|
||||
push(focus);
|
||||
for (let step = 1; step <= last - first; step += 1) {
|
||||
if (focus + step <= last) push(focus + step);
|
||||
if (focus - step >= first) push(focus - step);
|
||||
}
|
||||
for (let step = 1; step <= Math.max(0, Math.trunc(extra)); step += 1) {
|
||||
push(last + step);
|
||||
push(first - step);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
boxFromPin,
|
||||
boxFromRectangle,
|
||||
boxSideKm,
|
||||
coverageFor,
|
||||
boxCentre,
|
||||
dequantizeBox,
|
||||
fitViewHeightM,
|
||||
formatCandidateReadout,
|
||||
gibsTemplate,
|
||||
groupGranulesByDay,
|
||||
mergeCandidates,
|
||||
parseCandidateKey,
|
||||
quantizeBox,
|
||||
rankLatest,
|
||||
shortDay,
|
||||
thumbnailOrder,
|
||||
utcDay,
|
||||
validateBox,
|
||||
viewTooLargeMessage,
|
||||
viirsCandidates,
|
||||
wvsSnapshotUrl,
|
||||
} from './model.js';
|
||||
import { BOX } from './testDoubles.mjs';
|
||||
|
||||
function granule(overrides = {}) {
|
||||
return {
|
||||
id: 'g',
|
||||
product: 'S30',
|
||||
timeStart: '2026-09-18T17:12:00.000Z',
|
||||
timeEnd: '2026-09-18T17:12:30.000Z',
|
||||
cloud: 12,
|
||||
footprint: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hls(product, day, clouds, extra = {}) {
|
||||
const granules = clouds.map((cloud, index) =>
|
||||
granule({
|
||||
id: `${product}-${day}-${index}`,
|
||||
product,
|
||||
timeStart: `${day}T17:1${index}:00.000Z`,
|
||||
timeEnd: `${day}T17:1${index}:30.000Z`,
|
||||
cloud,
|
||||
}),
|
||||
);
|
||||
return { ...groupGranulesByDay(granules)[0], ...extra };
|
||||
}
|
||||
|
||||
test('validateBox refuses dateline, polar, degenerate, invalid and oversized boxes', () => {
|
||||
const reason = (box) => validateBox(box).reason;
|
||||
assert.equal(
|
||||
reason({ west: 179, south: 0, east: -179, north: 1 }),
|
||||
'dateline',
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: 0, south: 84, east: 0.5, north: 85.05111 }),
|
||||
'polar',
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: 0, south: -85.0512, east: 0.5, north: -84 }),
|
||||
'polar',
|
||||
);
|
||||
// Refusals fit the one-line notice.
|
||||
assert.equal(
|
||||
validateBox({ west: 0, south: 84, east: 0.5, north: 85.05111 }).message,
|
||||
'Imagery stops at ±85.05° latitude',
|
||||
);
|
||||
assert.equal(
|
||||
validateBox({ west: 179, south: 0, east: -179, north: 1 }).message,
|
||||
'Select one side of the dateline',
|
||||
);
|
||||
assert.equal(
|
||||
validateBox({ west: 0, south: 84.9, east: 0.05, north: 85 }).ok,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: 10, south: 10, east: 10, north: 11 }),
|
||||
'degenerate',
|
||||
);
|
||||
assert.equal(
|
||||
reason({ west: Infinity, south: 0, east: 1, north: 1 }),
|
||||
'invalid',
|
||||
);
|
||||
assert.equal(reason({ west: -181, south: 0, east: 1, north: 1 }), 'invalid');
|
||||
assert.equal(reason(undefined), 'invalid');
|
||||
// The cap is 1,000 km a side, and the refusal says how big the box is.
|
||||
const tall = validateBox({ west: 0, south: 0, east: 0.1, north: 10 });
|
||||
assert.equal(tall.reason, 'too-large');
|
||||
assert.equal(tall.message, 'Box is 1,113 km wide · limit 1,000 km');
|
||||
assert.equal(
|
||||
reason({ west: 0, south: 0, east: 10, north: 0.1 }),
|
||||
'too-large',
|
||||
);
|
||||
assert.equal(validateBox({ west: 0, south: 0, east: 8, north: 8 }).ok, true);
|
||||
assert.equal(
|
||||
viewTooLargeMessage(2400),
|
||||
'View is 2,400 km wide · limit 1,000 km',
|
||||
);
|
||||
// Latitudes are ordered; longitudes are never swapped.
|
||||
assert.deepEqual(
|
||||
validateBox({ west: -97.8, south: 30.3, east: -97.7, north: 30.2 }).box,
|
||||
BOX,
|
||||
);
|
||||
});
|
||||
|
||||
test('ZOOM IN: the height whose top-down view is 400 km wide, clamped to 5–400 km; a box centre', () => {
|
||||
const near = (actual, expected, label) =>
|
||||
assert.ok(Math.abs(actual - expected) < 1e-6, `${label}: ${actual}`);
|
||||
// A 1000 × 500 canvas with Cesium's 60° horizontal FOV: tan(fovy / 2) is
|
||||
// tan(30°) / 2, so the view is 2 · h · tan(30°) wide.
|
||||
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / 2);
|
||||
const height = fitViewHeightM({ width: 1000, height: 500, fovy });
|
||||
near(height, 400_000 / (2 * Math.tan(Math.PI / 6)), 'wide canvas');
|
||||
near(
|
||||
2 * height * Math.tan(fovy / 2) * 2,
|
||||
400_000,
|
||||
'the view at that height is 400 km wide',
|
||||
);
|
||||
// A tall canvas would need more than 400 km; a very wide one less than 5.
|
||||
assert.equal(
|
||||
fitViewHeightM({ width: 200, height: 1000, fovy: Math.PI / 3 }),
|
||||
400_000,
|
||||
);
|
||||
assert.equal(fitViewHeightM({ width: 1000, height: 500, fovy: 3.1 }), 5_000);
|
||||
// No canvas or FOV: a square 60° view.
|
||||
near(
|
||||
fitViewHeightM({ width: 0, height: 0, fovy: undefined }),
|
||||
400_000 / (2 * Math.tan(Math.PI / 6)),
|
||||
'fallback',
|
||||
);
|
||||
assert.deepEqual(boxCentre({ west: 0, south: 0, east: 20, north: 10 }), {
|
||||
lon: 10,
|
||||
lat: 5,
|
||||
});
|
||||
assert.equal(boxCentre(null), null);
|
||||
});
|
||||
|
||||
test('box width is the longitude span at the mid-latitude, never the shortest arc', () => {
|
||||
const equator = boxSideKm({ west: 0, south: -0.5, east: 1, north: 0.5 });
|
||||
assert.ok(Math.abs(equator.width - 111.32) < 1e-6);
|
||||
assert.ok(Math.abs(equator.height - 111.32) < 1e-6);
|
||||
const north = boxSideKm({ west: 0, south: 59.5, east: 1, north: 60.5 });
|
||||
assert.ok(north.width > 55 && north.width < 56.5, String(north.width));
|
||||
const nearlyGlobal = { west: -179.9, south: 30, east: 179.9, north: 30.1 };
|
||||
assert.ok(boxSideKm(nearlyGlobal).width > 30000);
|
||||
assert.equal(validateBox(nearlyGlobal).reason, 'too-large');
|
||||
});
|
||||
|
||||
test('boxFromPin builds a 10 km square within 2% at any latitude and refuses what cannot validate', () => {
|
||||
for (const [lon, lat] of [
|
||||
[-97.75, 30.27],
|
||||
[18.07, 59.33],
|
||||
[0, 0],
|
||||
[-70, -45],
|
||||
]) {
|
||||
const box = boxFromPin(lon, lat);
|
||||
const { width, height } = boxSideKm(box);
|
||||
assert.ok(Math.abs(width - 10) < 0.2, `width ${width} at ${lat}`);
|
||||
assert.ok(Math.abs(height - 10) < 0.2, `height ${height} at ${lat}`);
|
||||
assert.ok(Math.abs((box.west + box.east) / 2 - lon) < 1e-9);
|
||||
}
|
||||
assert.ok(Math.abs(boxSideKm(boxFromPin(10, 10, 50)).width - 50) < 1);
|
||||
assert.equal(boxFromPin(NaN, 0), null);
|
||||
assert.equal(boxFromPin(0, 0, 5000), null, 'over the cap');
|
||||
assert.equal(boxFromPin(179.99, 0), null, 'would cross the dateline');
|
||||
assert.equal(boxFromPin(0, 89), null, 'clamped to a degenerate polar box');
|
||||
assert.equal(boxFromPin(0, 85.04).north, 85.0511, 'clamped, still valid');
|
||||
});
|
||||
|
||||
test('rectangles convert from radians and the share-link quantization round-trips', () => {
|
||||
const box = boxFromRectangle({
|
||||
west: -Math.PI / 2,
|
||||
south: 0,
|
||||
east: -Math.PI / 4,
|
||||
north: Math.PI / 6,
|
||||
});
|
||||
assert.ok(Math.abs(box.west + 90) < 1e-9 && Math.abs(box.north - 30) < 1e-9);
|
||||
assert.equal(boxFromRectangle(null), null);
|
||||
const ints = quantizeBox({
|
||||
west: -97.812345678,
|
||||
south: 30.2,
|
||||
east: -97.7,
|
||||
north: 30.3999999,
|
||||
});
|
||||
assert.deepEqual(ints, {
|
||||
west: -9781235,
|
||||
south: 3020000,
|
||||
east: -9770000,
|
||||
north: 3040000,
|
||||
});
|
||||
assert.deepEqual(quantizeBox(dequantizeBox(ints)), ints);
|
||||
assert.equal(quantizeBox(null), null);
|
||||
assert.equal(dequantizeBox({ west: 'x' }), null);
|
||||
});
|
||||
|
||||
test('days and candidate keys are real UTC calendar days', () => {
|
||||
assert.equal(utcDay('2026-09-18T20:00:00-05:00'), '2026-09-19');
|
||||
assert.equal(utcDay(new Date(Date.UTC(2026, 0, 1))), '2026-01-01');
|
||||
assert.equal(utcDay('2026-02-30'), null);
|
||||
assert.equal(utcDay('nope'), null);
|
||||
assert.deepEqual(parseCandidateKey('L30:2026-09-10'), {
|
||||
product: 'L30',
|
||||
day: '2026-09-10',
|
||||
});
|
||||
assert.equal(parseCandidateKey('X30:2026-09-10'), null);
|
||||
assert.equal(parseCandidateKey('S30:2026-02-30'), null);
|
||||
assert.equal(shortDay('2026-09-18'), 'Sep 18');
|
||||
assert.equal(shortDay('nope'), '');
|
||||
});
|
||||
|
||||
test('granules group into one candidate per product and UTC day, newest first', () => {
|
||||
const candidates = groupGranulesByDay([
|
||||
granule({ id: 'a', cloud: 12, timeEnd: '2026-09-18T17:12:40Z' }),
|
||||
granule({
|
||||
id: 'b',
|
||||
cloud: 18,
|
||||
timeStart: '2026-09-18T17:20:00Z',
|
||||
timeEnd: '2026-09-18T17:20:40Z',
|
||||
}),
|
||||
granule({
|
||||
id: 'c',
|
||||
cloud: null,
|
||||
product: 'L30',
|
||||
timeStart: '2026-09-18T16:00:00Z',
|
||||
timeEnd: null,
|
||||
}),
|
||||
granule({ id: 'd', cloud: 3, timeStart: '2026-09-10T17:00:00Z' }),
|
||||
granule({ id: 'e', timeStart: 'garbage' }),
|
||||
granule({ id: 'f', product: 'nope' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
candidates.map((c) => c.key),
|
||||
['S30:2026-09-18', 'L30:2026-09-18', 'S30:2026-09-10'],
|
||||
);
|
||||
const [s18, l18] = candidates;
|
||||
assert.deepEqual(s18.cloud, { min: 12, max: 18 });
|
||||
assert.deepEqual(s18.timeRange, {
|
||||
start: '2026-09-18T17:12:00.000Z',
|
||||
end: '2026-09-18T17:20:40.000Z',
|
||||
});
|
||||
assert.equal(s18.availability, 'present');
|
||||
assert.equal(l18.cloud, null, 'missing cloud stays unknown');
|
||||
assert.equal(l18.timeRange.end, '2026-09-18T16:00:00.000Z');
|
||||
});
|
||||
|
||||
test('VIIRS lists the last 30 UTC days as unprobed full-coverage days', () => {
|
||||
const days = viirsCandidates('2026-03-01T13:00:00Z', 3);
|
||||
assert.deepEqual(
|
||||
days.map((c) => c.key),
|
||||
['VIIRS:2026-03-01', 'VIIRS:2026-02-28', 'VIIRS:2026-02-27'],
|
||||
);
|
||||
assert.equal(days[0].availability, 'unknown');
|
||||
assert.equal(days[0].coverage, 'full');
|
||||
assert.equal(viirsCandidates('2026-09-21').length, 30);
|
||||
assert.deepEqual(viirsCandidates('bad'), []);
|
||||
});
|
||||
|
||||
test('merging sorts newest day first, then S30, L30, VIIRS, and the first key wins', () => {
|
||||
const merged = mergeCandidates([
|
||||
viirsCandidates('2026-09-18', 2),
|
||||
[hls('L30', '2026-09-18', [5]), hls('S30', '2026-09-17', [5])],
|
||||
[hls('S30', '2026-09-18', [7]), hls('S30', '2026-09-18', [99])],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
merged.map((c) => c.key),
|
||||
[
|
||||
'S30:2026-09-18',
|
||||
'L30:2026-09-18',
|
||||
'VIIRS:2026-09-18',
|
||||
'S30:2026-09-17',
|
||||
'VIIRS:2026-09-17',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(merged[0].cloud, { min: 7, max: 7 });
|
||||
});
|
||||
|
||||
test('coverage samples the box corners and centre against the union of footprints', () => {
|
||||
const ring = (west, east) => [
|
||||
[west, 30],
|
||||
[east, 30],
|
||||
[east, 31],
|
||||
[west, 31],
|
||||
];
|
||||
const withRings = (...rings) => ({
|
||||
granules: rings.map((footprint) => granule({ footprint })),
|
||||
});
|
||||
assert.equal(coverageFor(withRings(ring(-98, -97)), BOX), 'full');
|
||||
assert.equal(coverageFor(withRings(ring(-98, -97.75)), BOX), 'partial');
|
||||
assert.equal(
|
||||
coverageFor(withRings(ring(-98, -97.75), ring(-97.75, -97)), BOX),
|
||||
'full',
|
||||
);
|
||||
assert.equal(coverageFor(withRings(null), BOX), 'unknown');
|
||||
});
|
||||
|
||||
test('START HERE is the newest all-clear HLS day; unknown cloud is never clear', () => {
|
||||
const candidates = [
|
||||
hls('S30', '2026-09-20', [10, null]),
|
||||
hls('S30', '2026-09-19', [5, 21]),
|
||||
hls('S30', '2026-09-18', [0, 20]),
|
||||
];
|
||||
assert.deepEqual(rankLatest([...candidates].reverse()), {
|
||||
candidate: candidates[2],
|
||||
reason: 'clear',
|
||||
certain: true,
|
||||
});
|
||||
assert.equal(rankLatest(candidates, { truncated: true }).certain, false);
|
||||
// Then the newest cloudy HLS day, then the newest non-empty VIIRS day.
|
||||
const cloudy = rankLatest([
|
||||
hls('L30', '2026-09-20', [45]),
|
||||
hls('S30', '2026-09-19', [null]),
|
||||
]);
|
||||
assert.equal(cloudy.candidate.key, 'L30:2026-09-20');
|
||||
assert.equal(cloudy.reason, 'cloudy');
|
||||
const viirs = viirsCandidates('2026-09-21', 3);
|
||||
viirs[0] = { ...viirs[0], availability: 'empty' };
|
||||
const empty = { ...hls('S30', '2026-09-21', [0]), availability: 'empty' };
|
||||
const overview = rankLatest([empty, ...viirs]);
|
||||
assert.equal(overview.candidate.key, 'VIIRS:2026-09-20');
|
||||
assert.equal(overview.reason, 'overview');
|
||||
assert.equal(rankLatest([]).candidate, null);
|
||||
});
|
||||
|
||||
test('START HERE prefers a day that covers the whole box over a newer sliver', () => {
|
||||
const full = (day, clouds) => hls('S30', day, clouds, { coverage: 'full' });
|
||||
const sliver = (day, clouds) =>
|
||||
hls('S30', day, clouds, { coverage: 'partial' });
|
||||
const pick = (list) => {
|
||||
const { candidate, reason } = rankLatest(list);
|
||||
return `${candidate?.day} ${reason}`;
|
||||
};
|
||||
assert.equal(
|
||||
pick([sliver('2026-09-20', [0]), full('2026-09-18', [10])]),
|
||||
'2026-09-18 clear',
|
||||
);
|
||||
assert.equal(
|
||||
pick([full('2026-09-20', [null]), sliver('2026-09-19', [0])]),
|
||||
'2026-09-20 cloudy',
|
||||
);
|
||||
// Unknown coverage (no footprints) counts as whole-box.
|
||||
assert.equal(
|
||||
pick([
|
||||
hls('S30', '2026-09-20', [5], { coverage: 'unknown' }),
|
||||
full('2026-09-18', [5]),
|
||||
]),
|
||||
'2026-09-20 clear',
|
||||
);
|
||||
// Only slivers: the newest clear one, then the newest at all.
|
||||
assert.equal(
|
||||
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [0])]),
|
||||
'2026-09-19 partial',
|
||||
);
|
||||
assert.equal(
|
||||
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [null])]),
|
||||
'2026-09-20 partial',
|
||||
);
|
||||
assert.equal(
|
||||
pick([...viirsCandidates('2026-09-21', 2), sliver('2026-09-19', [70])]),
|
||||
'2026-09-19 partial',
|
||||
'a sliver still beats the overview',
|
||||
);
|
||||
});
|
||||
|
||||
test('the readout is one UTC line with age, sensor, resolution and scene cloud', () => {
|
||||
const now = '2026-09-21T12:00:00Z';
|
||||
const readout = (granules) =>
|
||||
formatCandidateReadout(groupGranulesByDay(granules)[0], now);
|
||||
assert.equal(
|
||||
readout([granule({ timeStart: '2026-09-18T17:12:10Z' })]),
|
||||
'Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud',
|
||||
);
|
||||
assert.equal(
|
||||
readout([
|
||||
granule({ id: 'a' }),
|
||||
granule({
|
||||
id: 'b',
|
||||
cloud: 18.4,
|
||||
timeStart: '2026-09-18T17:20:00Z',
|
||||
timeEnd: '2026-09-18T17:20:30Z',
|
||||
}),
|
||||
]),
|
||||
'Sep 18, 2026 17:12–17:20Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12–18% scene cloud',
|
||||
);
|
||||
assert.equal(
|
||||
readout([
|
||||
granule({
|
||||
product: 'L30',
|
||||
cloud: null,
|
||||
timeStart: '2026-09-20T16:05:00Z',
|
||||
timeEnd: null,
|
||||
}),
|
||||
]),
|
||||
'Sep 20, 2026 16:05Z · yesterday · Landsat 8/9 via HLS · 30 m · cloud unknown',
|
||||
);
|
||||
assert.equal(
|
||||
formatCandidateReadout(viirsCandidates('2026-09-21', 1)[0], now),
|
||||
'Sep 21, 2026 · today · VIIRS NOAA-21 · 250 m · overview · cloud unknown',
|
||||
);
|
||||
assert.equal(formatCandidateReadout(null), '');
|
||||
});
|
||||
|
||||
test('GIBS templates put y before x; Worldview snapshots take south,west,north,east', () => {
|
||||
assert.equal(
|
||||
gibsTemplate('S30', '2026-09-18'),
|
||||
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/HLS_S30_Nadir_BRDF_Adjusted_Reflectance/default/2026-09-18/GoogleMapsCompatible_Level12/{z}/{y}/{x}.png',
|
||||
);
|
||||
assert.equal(
|
||||
gibsTemplate('VIIRS', '2026-09-21'),
|
||||
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA21_CorrectedReflectance_TrueColor/default/2026-09-21/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg',
|
||||
);
|
||||
assert.match(gibsTemplate('L30', '2026-09-10'), /HLS_L30_.*Level12.*\.png$/);
|
||||
assert.throws(() => gibsTemplate('MODIS', '2026-01-01'), /Unknown/);
|
||||
assert.equal(
|
||||
wvsSnapshotUrl({
|
||||
product: 'S30',
|
||||
day: '2026-09-18',
|
||||
box: BOX,
|
||||
width: 256,
|
||||
height: 256,
|
||||
}),
|
||||
'https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance&CRS=EPSG:4326&TIME=2026-09-18&BBOX=30.2,-97.8,30.3,-97.7&WIDTH=256&HEIGHT=256&FORMAT=image/png',
|
||||
);
|
||||
assert.throws(
|
||||
() => wvsSnapshotUrl({ product: 'S30', day: 'x', box: null }),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('thumbnails load the focused card, then the visible cards outward, then the margins', () => {
|
||||
assert.deepEqual(thumbnailOrder(5, 3, 7, 20), [5, 6, 4, 7, 3, 8, 2, 9, 1]);
|
||||
assert.deepEqual(thumbnailOrder(19, 17, 19, 20), [19, 18, 17, 16, 15]);
|
||||
assert.deepEqual(thumbnailOrder(50, 7, 3, 10), [7, 6, 5, 4, 3, 8, 2, 9, 1]);
|
||||
assert.deepEqual(thumbnailOrder(0, 0, 0, 0), []);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Draped-imagery renderer for the Recent Imagery layer: at most two GIBS
|
||||
* imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever
|
||||
* host the map stack offers. Against the basemap slot `a` splits left and
|
||||
* slot `b` stays empty. Every layer is removed from the collection that owns it, so a
|
||||
* host switch or a replaced image never strands one. Tile requests pass a
|
||||
* small semaphore of our own; Cesium's global request limits stay untouched.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { PRODUCTS, gibsTemplate } from './model.js';
|
||||
|
||||
const SLOT_IDS = ['a', 'b'];
|
||||
const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' };
|
||||
const NO_HOST = Object.freeze({ collection: null, kind: 'none' });
|
||||
|
||||
/** Render reason for a deferred or settled tile request. */
|
||||
export const TILE_RENDER_REASON = 'recent-imagery-tiles';
|
||||
/** Delay before the one frame that retries tiles Cesium itself deferred. */
|
||||
export const TILE_RETRY_DELAY_MS = 250;
|
||||
|
||||
function clampAlpha(value) {
|
||||
const alpha = Number(value);
|
||||
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
|
||||
}
|
||||
|
||||
const boxKey = (box) =>
|
||||
[box.west, box.south, box.east, box.north].map(Number).join(',');
|
||||
|
||||
/**
|
||||
* Gate a provider's `requestImage` through the semaphore. Returning
|
||||
* `undefined` tells Cesium to retry on a later frame, but under
|
||||
* `requestRenderMode` there is no later frame unless something asks — and a
|
||||
* deferral must never ask for one itself: the retried tile would defer again
|
||||
* and ask again, keeping the scene rendering every frame. So a request that
|
||||
* finds our semaphore full asks for nothing (the request holding the slot
|
||||
* asks for one frame when it settles), and a request the provider itself
|
||||
* deferred goes through the one coalesced delayed retry.
|
||||
*/
|
||||
function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
const original = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (admission.inFlight >= admission.limit) return undefined;
|
||||
admission.inFlight += 1;
|
||||
const release = () => {
|
||||
admission.inFlight -= 1;
|
||||
};
|
||||
let promise;
|
||||
try {
|
||||
promise = original(x, y, level, request);
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
if (!promise) {
|
||||
release();
|
||||
retry.schedule();
|
||||
return undefined;
|
||||
}
|
||||
return Promise.resolve(promise).finally(() => {
|
||||
release();
|
||||
requestRender(TILE_RENDER_REASON);
|
||||
});
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the renderer.
|
||||
* @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options]
|
||||
*/
|
||||
export function createRecentImageryRenderer({
|
||||
cesium = Cesium,
|
||||
maxTileRequests = 6,
|
||||
requestRender = () => {},
|
||||
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
|
||||
} = {}) {
|
||||
let host = NO_HOST;
|
||||
let destroyed = false;
|
||||
const admission = { limit: maxTileRequests, inFlight: 0 };
|
||||
/** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */
|
||||
const owned = { a: null, b: null };
|
||||
|
||||
// One timer for both slots: any number of upstream deferrals while it is
|
||||
// armed share the same single retry frame.
|
||||
let retryTimer = null;
|
||||
const retry = {
|
||||
schedule() {
|
||||
if (retryTimer !== null) return;
|
||||
retryTimer = setTimeoutImpl(() => {
|
||||
retryTimer = null;
|
||||
if (!destroyed) requestRender(TILE_RENDER_REASON);
|
||||
}, TILE_RETRY_DELAY_MS);
|
||||
},
|
||||
cancel() {
|
||||
if (retryTimer !== null) clearTimeoutImpl(retryTimer);
|
||||
retryTimer = null;
|
||||
},
|
||||
};
|
||||
|
||||
function createProvider(candidate, box) {
|
||||
const spec = PRODUCTS[candidate.product];
|
||||
const provider = new cesium.UrlTemplateImageryProvider({
|
||||
url: gibsTemplate(candidate.product, candidate.day),
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
minimumLevel: 0,
|
||||
maximumLevel: spec.maxLevel,
|
||||
tilingScheme: new cesium.WebMercatorTilingScheme(),
|
||||
rectangle: cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
),
|
||||
credit: 'NASA GIBS',
|
||||
hasAlphaChannel: spec.format === 'png',
|
||||
});
|
||||
return admitTileRequests(provider, admission, requestRender, retry);
|
||||
}
|
||||
|
||||
function removeOwned(slotId) {
|
||||
const record = owned[slotId];
|
||||
if (!record) return false;
|
||||
owned[slotId] = null;
|
||||
try {
|
||||
record.collection.remove(record.layer, true);
|
||||
} catch {
|
||||
/* the collection is already gone */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyLook(record) {
|
||||
record.layer.alpha = record.alpha;
|
||||
record.layer.splitDirection =
|
||||
cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0;
|
||||
}
|
||||
|
||||
function mount(slotId, { candidate, box, alpha, split }) {
|
||||
const layer = host.collection.addImageryProvider(
|
||||
createProvider(candidate, box),
|
||||
);
|
||||
owned[slotId] = {
|
||||
layer,
|
||||
collection: host.collection,
|
||||
candidate,
|
||||
box,
|
||||
alpha,
|
||||
split,
|
||||
};
|
||||
applyLook(owned[slotId]);
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Drape a candidate into a slot. The same candidate on the same host only
|
||||
* updates its look; anything else replaces the slot's layer.
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
|
||||
* @returns {boolean} Whether a layer is now draped for the slot.
|
||||
*/
|
||||
showSlot(
|
||||
slotId,
|
||||
candidate,
|
||||
box,
|
||||
{ alpha = 1, splitDirection = 'none' } = {},
|
||||
) {
|
||||
if (destroyed || !candidate?.key || !box) return false;
|
||||
if (!host.collection) {
|
||||
removeOwned(slotId);
|
||||
return false;
|
||||
}
|
||||
const current = owned[slotId];
|
||||
const look = { alpha: clampAlpha(alpha), split: splitDirection };
|
||||
if (
|
||||
current &&
|
||||
current.collection === host.collection &&
|
||||
current.candidate.key === candidate.key &&
|
||||
boxKey(current.box) === boxKey(box)
|
||||
) {
|
||||
if (current.alpha !== look.alpha || current.split !== look.split) {
|
||||
Object.assign(current, look);
|
||||
applyLook(current);
|
||||
requestRender('recent-imagery-look');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
removeOwned(slotId);
|
||||
mount(slotId, { candidate, box, ...look });
|
||||
requestRender('recent-imagery-show');
|
||||
return true;
|
||||
},
|
||||
|
||||
/** @param {'a'|'b'} slotId */
|
||||
hideSlot(slotId) {
|
||||
if (removeOwned(slotId)) requestRender('recent-imagery-hide');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {number} alpha 0–1
|
||||
*/
|
||||
setAlpha(slotId, alpha) {
|
||||
const record = owned[slotId];
|
||||
const next = clampAlpha(alpha);
|
||||
if (!record || record.alpha === next) return;
|
||||
record.alpha = next;
|
||||
applyLook(record);
|
||||
requestRender('recent-imagery-alpha');
|
||||
},
|
||||
|
||||
/**
|
||||
* Move every owned layer to a new host. Layers are rebuilt there, never
|
||||
* re-parented, so the old collection is left as it was found.
|
||||
* @param {{ collection: object | null, kind: string }} nextHost
|
||||
*/
|
||||
rebind(nextHost) {
|
||||
host = nextHost || NO_HOST;
|
||||
let changed = false;
|
||||
for (const slotId of SLOT_IDS) {
|
||||
const record = owned[slotId];
|
||||
if (!record || record.collection === host.collection) continue;
|
||||
removeOwned(slotId);
|
||||
changed = true;
|
||||
if (host.collection && !destroyed) mount(slotId, record);
|
||||
}
|
||||
if (changed) requestRender('recent-imagery-rebind');
|
||||
},
|
||||
|
||||
/** What is draped right now, per slot. */
|
||||
getOwned() {
|
||||
const out = {};
|
||||
for (const slotId of SLOT_IDS) {
|
||||
const record = owned[slotId];
|
||||
out[slotId] = record
|
||||
? {
|
||||
key: record.candidate.key,
|
||||
kind: host.kind,
|
||||
alpha: record.alpha,
|
||||
split: record.split,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Number of layers this renderer owns (never above two). */
|
||||
ownedCount() {
|
||||
return SLOT_IDS.filter((slotId) => owned[slotId]).length;
|
||||
},
|
||||
|
||||
/** Remove everything and refuse further work. */
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
retry.cancel();
|
||||
let changed = false;
|
||||
for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed;
|
||||
host = NO_HOST;
|
||||
if (changed) requestRender('recent-imagery-destroy');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TILE_RENDER_REASON,
|
||||
TILE_RETRY_DELAY_MS,
|
||||
createRecentImageryRenderer,
|
||||
} from './rendering.js';
|
||||
import { BOX, manualTimers } from './testDoubles.mjs';
|
||||
|
||||
/** A provider whose tile requests stay pending until the test settles them. */
|
||||
class UrlTemplateImageryProvider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.calls = [];
|
||||
this.pending = [];
|
||||
}
|
||||
requestImage(x, y, level) {
|
||||
this.calls.push([x, y, level]);
|
||||
return new Promise((resolve) => this.pending.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function fakeCesium(requestImage) {
|
||||
class Provider extends UrlTemplateImageryProvider {}
|
||||
if (requestImage) Provider.prototype.requestImage = requestImage;
|
||||
return {
|
||||
UrlTemplateImageryProvider: Provider,
|
||||
WebMercatorTilingScheme: class {},
|
||||
Rectangle: {
|
||||
fromDegrees: (west, south, east, north) => ({ west, south, east, north }),
|
||||
},
|
||||
SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCollection() {
|
||||
const layers = [];
|
||||
return {
|
||||
layers,
|
||||
addImageryProvider(provider) {
|
||||
const layer = { provider, alpha: 1, splitDirection: 0 };
|
||||
layers.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
layers.splice(layers.indexOf(layer), 1);
|
||||
layer.destroyed = destroy;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
|
||||
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
|
||||
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
|
||||
|
||||
function fixture({ requestImage, maxTileRequests } = {}) {
|
||||
const renders = [];
|
||||
const timers = manualTimers();
|
||||
const renderer = createRecentImageryRenderer({
|
||||
cesium: fakeCesium(requestImage),
|
||||
maxTileRequests,
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
setTimeoutImpl: timers.setTimeoutImpl,
|
||||
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||
});
|
||||
const globe = fakeCollection();
|
||||
renderer.rebind({ collection: globe, kind: 'globe' });
|
||||
const tileFrames = () =>
|
||||
renders.filter((reason) => reason === TILE_RENDER_REASON).length;
|
||||
return { renderer, globe, renders, timers, tileFrames };
|
||||
}
|
||||
|
||||
test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => {
|
||||
const { renderer, globe, renders } = fixture();
|
||||
assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
|
||||
const [layer] = globe.layers;
|
||||
assert.match(
|
||||
layer.provider.options.url,
|
||||
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
|
||||
);
|
||||
assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']);
|
||||
assert.equal(layer.provider.options.credit, 'NASA GIBS');
|
||||
assert.deepEqual(layer.provider.options.rectangle, BOX);
|
||||
assert.equal(layer.alpha, 0.8);
|
||||
assert.deepEqual(renders, ['recent-imagery-show']);
|
||||
assert.deepEqual(renderer.getOwned().a, {
|
||||
key: 'S30:2026-09-18',
|
||||
kind: 'globe',
|
||||
alpha: 0.8,
|
||||
split: 'none',
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
|
||||
assert.deepEqual(globe.layers, [layer], 'the layer is reused');
|
||||
assert.equal(layer.splitDirection, -1);
|
||||
// A different day replaces the layer in its own collection.
|
||||
renderer.showSlot('a', L30, BOX);
|
||||
assert.equal(layer.destroyed, true);
|
||||
assert.equal(globe.layers.length, 1);
|
||||
// VIIRS is a level-9 JPEG overview with no alpha channel.
|
||||
renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
|
||||
assert.equal(globe.layers[1].provider.options.maximumLevel, 9);
|
||||
assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false);
|
||||
assert.equal(globe.layers[1].splitDirection, 1);
|
||||
renderer.setAlpha('b', 7);
|
||||
assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1');
|
||||
});
|
||||
|
||||
test('rapid replacement never owns more than two layers', () => {
|
||||
const { renderer, globe } = fixture();
|
||||
const days = [S30, L30, VIIRS];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
renderer.showSlot('a', days[i % 3], BOX);
|
||||
renderer.showSlot('b', days[(i + 1) % 3], BOX);
|
||||
if (i % 4 === 0) renderer.hideSlot('a');
|
||||
assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2);
|
||||
}
|
||||
renderer.hideSlot('a');
|
||||
renderer.hideSlot('b');
|
||||
assert.equal(globe.layers.length, 0);
|
||||
});
|
||||
|
||||
test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => {
|
||||
const { renderer, globe } = fixture();
|
||||
renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' });
|
||||
renderer.showSlot('b', L30, BOX, { splitDirection: 'right' });
|
||||
const tileset = fakeCollection();
|
||||
renderer.rebind({ collection: tileset, kind: 'tileset' });
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.deepEqual(
|
||||
tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]),
|
||||
[
|
||||
[0.6, -1],
|
||||
[1, 1],
|
||||
],
|
||||
);
|
||||
assert.equal(renderer.getOwned().a.kind, 'tileset');
|
||||
renderer.rebind({ collection: null, kind: 'none' });
|
||||
assert.equal(tileset.layers.length, 0);
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), false);
|
||||
renderer.rebind({ collection: globe, kind: 'globe' });
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
renderer.destroy();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed');
|
||||
});
|
||||
|
||||
test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => {
|
||||
const { renderer, globe, timers, tileFrames } = fixture({
|
||||
maxTileRequests: 1,
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
const { provider } = globe.layers[0];
|
||||
const first = provider.requestImage(0, 0, 1);
|
||||
assert.ok(first instanceof Promise);
|
||||
for (let i = 1; i <= 120; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 1), undefined);
|
||||
assert.equal(provider.calls.length, 1, 'deferred requests never go out');
|
||||
assert.equal(tileFrames(), 0, 'a deferral asks for no frame');
|
||||
assert.equal(timers.armed(), 0);
|
||||
provider.pending[0]('tile');
|
||||
await first;
|
||||
assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame');
|
||||
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
|
||||
});
|
||||
|
||||
test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => {
|
||||
let throwNext = false;
|
||||
const { renderer, globe, timers, tileFrames } = fixture({
|
||||
maxTileRequests: 1,
|
||||
requestImage() {
|
||||
if (throwNext) throw new Error('boom');
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
renderer.showSlot('b', L30, BOX);
|
||||
for (const { provider } of globe.layers)
|
||||
for (let i = 0; i < 40; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 0), undefined);
|
||||
// Every deferral released its slot (the limit is one), and none asked
|
||||
// for a frame of its own: one timer holds the single retry.
|
||||
assert.equal(tileFrames(), 0);
|
||||
assert.equal(timers.armed(), 1);
|
||||
assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
|
||||
timers.flush();
|
||||
assert.equal(tileFrames(), 1);
|
||||
// A throwing provider releases its slot too.
|
||||
throwNext = true;
|
||||
assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/);
|
||||
throwNext = false;
|
||||
globe.layers[0].provider.requestImage(0, 0, 0);
|
||||
assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer');
|
||||
renderer.destroy();
|
||||
assert.equal(timers.armed(), 0, 'destroy disarms the retry');
|
||||
assert.equal(tileFrames(), 1);
|
||||
});
|
||||
|
||||
test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => {
|
||||
const { renderer, globe, renders } = fixture();
|
||||
renderer.showSlot('a', S30, BOX, { splitDirection: 'left' });
|
||||
renderer.hideSlot('b');
|
||||
assert.equal(globe.layers.length, 1, 'the basemap is the right side');
|
||||
assert.equal(globe.layers[0].splitDirection, -1);
|
||||
assert.equal(renderer.getOwned().b, null);
|
||||
const [layer] = globe.layers;
|
||||
renderer.showSlot('a', S30, BOX, { splitDirection: 'none' });
|
||||
assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look');
|
||||
assert.equal(layer.splitDirection, 0);
|
||||
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
|
||||
* responses, and the Cesium/viewer/document fakes the box tool needs.
|
||||
*/
|
||||
import { thumbnailOrder } from './model.js';
|
||||
|
||||
export const BOX = Object.freeze({
|
||||
west: -97.8,
|
||||
south: 30.2,
|
||||
east: -97.7,
|
||||
north: 30.3,
|
||||
});
|
||||
|
||||
export const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** Timers that fire only when the test says so. */
|
||||
export function manualTimers() {
|
||||
const pending = new Map();
|
||||
const cleared = [];
|
||||
let nextId = 1;
|
||||
return {
|
||||
pending,
|
||||
cleared,
|
||||
setTimeoutImpl(fn, ms) {
|
||||
const id = nextId++;
|
||||
pending.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeoutImpl(id) {
|
||||
cleared.push(id);
|
||||
pending.delete(id);
|
||||
},
|
||||
/** Fire every armed timer, in order. */
|
||||
flush() {
|
||||
const entries = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const entry of entries) entry.fn();
|
||||
},
|
||||
armed: () => pending.size,
|
||||
};
|
||||
}
|
||||
|
||||
/** A `fetch` response with case-insensitive headers. */
|
||||
export function response({
|
||||
ok = true,
|
||||
status = ok ? 200 : 500,
|
||||
json = {},
|
||||
headers = {},
|
||||
blob = { size: 1 },
|
||||
} = {}) {
|
||||
const lower = new Map(
|
||||
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
headers: { get: (name) => lower.get(name.toLowerCase()) ?? null },
|
||||
json: async () => json,
|
||||
blob: async () => blob,
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_TYPES = {
|
||||
LEFT_DOWN: 'down',
|
||||
MOUSE_MOVE: 'move',
|
||||
LEFT_UP: 'up',
|
||||
LEFT_CLICK: 'click',
|
||||
LEFT_DOUBLE_CLICK: 'dblclick',
|
||||
};
|
||||
|
||||
/**
|
||||
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
|
||||
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
|
||||
*/
|
||||
export function boxToolFakes() {
|
||||
const handlers = [];
|
||||
class ScreenSpaceEventHandler {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.actions = new Map();
|
||||
this.destroyed = false;
|
||||
handlers.push(this);
|
||||
}
|
||||
setInputAction(fn, type) {
|
||||
this.actions.set(type, fn);
|
||||
}
|
||||
getInputAction(type) {
|
||||
return this.actions.get(type);
|
||||
}
|
||||
removeInputAction(type) {
|
||||
this.actions.delete(type);
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class CustomDataSource {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
const values = [];
|
||||
this.entities = {
|
||||
values,
|
||||
add: (entity) => (values.push(entity), entity),
|
||||
removeAll: () => values.splice(0),
|
||||
};
|
||||
}
|
||||
}
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const cesium = {
|
||||
ScreenSpaceEventHandler,
|
||||
ScreenSpaceEventType: EVENT_TYPES,
|
||||
CustomDataSource,
|
||||
CallbackProperty: class {},
|
||||
PolylineDashMaterialProperty: class {},
|
||||
ClassificationType: { BOTH: 'both' },
|
||||
Color: { fromCssColorString: () => color },
|
||||
Rectangle: { fromDegrees: () => ({}) },
|
||||
Cartesian3: { fromDegreesArray: (values) => values },
|
||||
};
|
||||
|
||||
const stock = new ScreenSpaceEventHandler('stock');
|
||||
const originalClick = () => 'select';
|
||||
const originalDouble = () => 'track';
|
||||
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
|
||||
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
renders: 0,
|
||||
scene: {
|
||||
canvas: { clientWidth: 1000, clientHeight: 500 },
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
requestRender: () => {
|
||||
viewer.renders += 1;
|
||||
},
|
||||
},
|
||||
screenSpaceEventHandler: stock,
|
||||
dataSources: {
|
||||
sources,
|
||||
add: (source) => (sources.push(source), Promise.resolve(source)),
|
||||
remove: (source) => sources.splice(sources.indexOf(source), 1),
|
||||
},
|
||||
};
|
||||
|
||||
const listeners = [];
|
||||
const classes = new Set();
|
||||
const documentRef = {
|
||||
listeners,
|
||||
classes,
|
||||
addEventListener: (type, fn) => listeners.push([type, fn]),
|
||||
removeEventListener: (type, fn) => {
|
||||
const index = listeners.findIndex((entry) => entry[1] === fn);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
},
|
||||
body: {
|
||||
classList: {
|
||||
add: (name) => classes.add(name),
|
||||
remove: (name) => classes.delete(name),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pickWorld = (_viewer, nx, ny) =>
|
||||
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
|
||||
/** The live (non-stock) canvas handler, if the tool has one bound. */
|
||||
const handler = () =>
|
||||
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
|
||||
const fire = (type, event) =>
|
||||
handler()?.actions.get(EVENT_TYPES[type])?.(event);
|
||||
/** Dispatch a keydown to the tool's document listener. */
|
||||
const key = (name) => {
|
||||
const event = {
|
||||
key: name,
|
||||
prevented: false,
|
||||
stopped: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
stopImmediatePropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
listeners.find(([type]) => type === 'keydown')?.[1](event);
|
||||
return event;
|
||||
};
|
||||
return {
|
||||
cesium,
|
||||
viewer,
|
||||
documentRef,
|
||||
pickWorld,
|
||||
handler,
|
||||
fire,
|
||||
key,
|
||||
types: EVENT_TYPES,
|
||||
originalClick,
|
||||
originalDouble,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- the layer's injected parts --------------------------------------
|
||||
|
||||
export function candidate(product, day, cloud = 5) {
|
||||
const granules =
|
||||
product === 'VIIRS'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: `${product}-${day}`,
|
||||
product,
|
||||
timeStart: `${day}T17:12:00Z`,
|
||||
timeEnd: `${day}T17:14:00Z`,
|
||||
cloud,
|
||||
},
|
||||
];
|
||||
return {
|
||||
key: `${product}:${day}`,
|
||||
product,
|
||||
day,
|
||||
granules,
|
||||
cloud: granules.length ? { min: cloud, max: cloud } : null,
|
||||
timeRange: granules.length
|
||||
? { start: granules[0].timeStart, end: granules[0].timeEnd }
|
||||
: null,
|
||||
availability: granules.length ? 'present' : 'unknown',
|
||||
coverage: 'full',
|
||||
};
|
||||
}
|
||||
|
||||
// Strip order, newest first: V21 (unprobed), S18 (clear), L16 (cloudy), V15.
|
||||
export const CANDIDATES = Object.freeze([
|
||||
candidate('VIIRS', '2026-09-21'),
|
||||
candidate('S30', '2026-09-18', 12),
|
||||
candidate('L30', '2026-09-16', 60),
|
||||
candidate('VIIRS', '2026-09-15'),
|
||||
]);
|
||||
|
||||
export function fakeRenderer() {
|
||||
const calls = [];
|
||||
const owned = { a: null, b: null };
|
||||
let host = { kind: 'none' };
|
||||
let peak = 0;
|
||||
return {
|
||||
calls,
|
||||
rebind(next) {
|
||||
calls.push(['rebind', next.kind]);
|
||||
host = next;
|
||||
for (const slot of ['a', 'b'])
|
||||
if (owned[slot]) owned[slot] = { ...owned[slot], kind: next.kind };
|
||||
},
|
||||
showSlot(slot, cand, box, look) {
|
||||
if (owned[slot]?.key !== cand.key) calls.push(['show', slot, cand.key]);
|
||||
owned[slot] = { key: cand.key, kind: host.kind, ...look };
|
||||
peak = Math.max(peak, this.ownedCount());
|
||||
return true;
|
||||
},
|
||||
hideSlot(slot) {
|
||||
owned[slot] = null;
|
||||
},
|
||||
setAlpha(slot, alpha) {
|
||||
calls.push(['alpha', slot, alpha]);
|
||||
},
|
||||
getOwned: () => ({ ...owned }),
|
||||
ownedCount: () => ['a', 'b'].filter((slot) => owned[slot]).length,
|
||||
peak: () => peak,
|
||||
destroy() {
|
||||
calls.push(['destroy']);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeThumbnails() {
|
||||
const calls = [];
|
||||
const listeners = new Set();
|
||||
const statuses = new Map();
|
||||
/** Every key the layer asked to probe, in the order it asked. */
|
||||
const requested = [];
|
||||
/** Direct `request` calls: `[key, priority]`. */
|
||||
const requests = [];
|
||||
const ask = (key) => {
|
||||
if (!requested.includes(key)) requested.push(key);
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
requested,
|
||||
requests,
|
||||
requestOrdered(candidates, box, window) {
|
||||
calls.push(['ordered', candidates.map((c) => c.key), window]);
|
||||
const { focusIndex, firstVisible, lastVisible, extra } = window;
|
||||
thumbnailOrder(
|
||||
focusIndex,
|
||||
firstVisible,
|
||||
lastVisible,
|
||||
candidates.length,
|
||||
extra,
|
||||
).forEach((index) => ask(candidates[index].key));
|
||||
},
|
||||
request(candidate, box, priority) {
|
||||
ask(candidate.key);
|
||||
requests.push([candidate.key, priority]);
|
||||
},
|
||||
/** Settle every requested key still unknown, as the loader would. */
|
||||
probeRequested(status = 'present') {
|
||||
for (const key of requested)
|
||||
if (!statuses.has(key)) this.probe(key, status);
|
||||
},
|
||||
clear() {
|
||||
calls.push(['clear']);
|
||||
},
|
||||
get: (key) => ({ status: statuses.get(key) || 'unknown' }),
|
||||
/** Record a probe result and tell the layer, as the loader does. */
|
||||
probe(key, status) {
|
||||
statuses.set(key, status);
|
||||
for (const listener of listeners) listener(key);
|
||||
},
|
||||
/** Record a probe result without telling anyone yet. */
|
||||
setStatus: (key, status) => statuses.set(key, status),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
destroy() {
|
||||
calls.push(['destroy']);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeCatalog() {
|
||||
const searches = [];
|
||||
return {
|
||||
searches,
|
||||
searchHls(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
searches.push({ request, resolve, reject });
|
||||
});
|
||||
},
|
||||
resolveLast(candidates = CANDIDATES) {
|
||||
searches.at(-1).resolve({ candidates, truncated: false, errors: [] });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A map controller whose lease settles when the test says so. */
|
||||
export function fakeController({ refuse = false } = {}) {
|
||||
const calls = [];
|
||||
const controller = {
|
||||
calls,
|
||||
acquireImageryComparison(options) {
|
||||
calls.push(['acquire', options.owner, options.switchPolicy]);
|
||||
if (refuse) throw new Error('held by bhote');
|
||||
let settle;
|
||||
const ready = new Promise((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
controller.lease = {
|
||||
ready,
|
||||
release: () => {
|
||||
calls.push(['release']);
|
||||
return Promise.resolve();
|
||||
},
|
||||
settle: (result = { status: 'ready' }) => settle(result),
|
||||
};
|
||||
return controller.lease;
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Thumbnail loader for the Recent Imagery strip: one Worldview snapshot per
|
||||
* candidate day. The response's `Data-Present` header is the only proof that
|
||||
* a day has pixels in the box (GIBS answers an empty tile with HTTP 200), and
|
||||
* `Acquisition-Time` carries the sensing time. Fetches are bounded, and so
|
||||
* are decoded images: residency is least-recently-USED, where a request or a
|
||||
* completed decode is a use and a `get` read never is (the panel reads every
|
||||
* card on every render, so a read that counted would evict the visible cards
|
||||
* first). Eviction revokes the image only; what the probe proved is kept.
|
||||
*/
|
||||
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
|
||||
|
||||
const EMPTY = Object.freeze({
|
||||
status: 'unknown',
|
||||
objectUrl: null,
|
||||
acquisitionTime: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{ fetchImpl?: typeof fetch, maxInFlight?: number, maxDecoded?: number, createObjectUrl?: (blob: Blob) => string, revokeObjectUrl?: (url: string) => void, size?: number }} [options]
|
||||
*/
|
||||
export function createThumbnailLoader({
|
||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||
maxInFlight = 3,
|
||||
maxDecoded = 16,
|
||||
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
||||
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
|
||||
size = 256,
|
||||
} = {}) {
|
||||
const limit = Math.max(1, Math.trunc(maxInFlight) || 1);
|
||||
const resident = Math.max(1, Math.trunc(maxDecoded) || 1);
|
||||
const entries = new Map();
|
||||
const listeners = new Set();
|
||||
const queue = [];
|
||||
let inFlight = 0;
|
||||
let touchCounter = 0;
|
||||
let destroyed = false;
|
||||
|
||||
function notify(key) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(key);
|
||||
} catch (error) {
|
||||
console.warn('[Data:RecentImagery] thumbnail listener failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function touch(entry) {
|
||||
touchCounter += 1;
|
||||
entry.touched = touchCounter;
|
||||
}
|
||||
|
||||
function revokeImage(entry) {
|
||||
if (!entry.objectUrl) return;
|
||||
try {
|
||||
revokeObjectUrl(entry.objectUrl);
|
||||
} catch {
|
||||
/* a revoked URL is already gone */
|
||||
}
|
||||
entry.objectUrl = null;
|
||||
}
|
||||
|
||||
function evictDecoded(keep) {
|
||||
const decoded = [...entries.values()]
|
||||
.filter((entry) => entry.objectUrl && entry !== keep)
|
||||
.sort((a, b) => a.touched - b.touched);
|
||||
let count = decoded.length + (keep?.objectUrl ? 1 : 0);
|
||||
for (const entry of decoded) {
|
||||
if (count <= resident) break;
|
||||
revokeImage(entry);
|
||||
notify(entry.key);
|
||||
count -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(entry) {
|
||||
if (!queue.includes(entry)) queue.push(entry);
|
||||
pump();
|
||||
}
|
||||
|
||||
async function run(entry) {
|
||||
const controller = new AbortController();
|
||||
entry.controller = controller;
|
||||
entry.loading = true;
|
||||
inFlight += 1;
|
||||
let status = 'error';
|
||||
let objectUrl = null;
|
||||
let acquisitionTime = null;
|
||||
let cancelled = false;
|
||||
try {
|
||||
const url = wvsSnapshotUrl({
|
||||
product: entry.candidate.product,
|
||||
day: entry.candidate.day,
|
||||
box: entry.box,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (controller.signal.aborted) {
|
||||
cancelled = true;
|
||||
} else if (response?.ok) {
|
||||
const present = String(
|
||||
response.headers?.get?.('Data-Present') ?? 'true',
|
||||
).toLowerCase();
|
||||
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
|
||||
if (present === 'false') {
|
||||
status = 'empty';
|
||||
} else {
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted) cancelled = true;
|
||||
else {
|
||||
objectUrl = createObjectUrl(blob);
|
||||
status = 'present';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || error?.name === 'AbortError')
|
||||
cancelled = true;
|
||||
else status = 'error';
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
entry.loading = false;
|
||||
entry.controller = null;
|
||||
}
|
||||
// Every settlement frees a slot, so a cancelled fetch pumps too.
|
||||
if (cancelled || destroyed || entries.get(entry.key) !== entry) {
|
||||
if (objectUrl) revokeObjectUrl(objectUrl);
|
||||
pump();
|
||||
return;
|
||||
}
|
||||
// A failed re-fetch of an evicted image keeps what the day proved.
|
||||
if (status !== 'error' || entry.status !== 'present') {
|
||||
entry.status = status;
|
||||
entry.acquisitionTime = acquisitionTime;
|
||||
}
|
||||
entry.objectUrl = objectUrl;
|
||||
touch(entry);
|
||||
if (objectUrl) evictDecoded(entry);
|
||||
notify(entry.key);
|
||||
pump();
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (destroyed) return;
|
||||
queue.sort((a, b) => a.priority - b.priority);
|
||||
while (inFlight < limit && queue.length) {
|
||||
const entry = queue.shift();
|
||||
if (entries.get(entry.key) !== entry) continue;
|
||||
void run(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Ask for one candidate's thumbnail. Lower priority loads sooner. A day
|
||||
* already known is only touched (and re-prioritised while it waits); a
|
||||
* known-present day whose image was evicted is fetched again.
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @param {number} [priority]
|
||||
*/
|
||||
request(candidate, box, priority = 0) {
|
||||
if (destroyed || !candidate?.key || !box) return;
|
||||
const key = candidate.key;
|
||||
const existing = entries.get(key);
|
||||
if (existing) {
|
||||
touch(existing);
|
||||
if (existing.loading) return;
|
||||
if (existing.status === 'unknown') {
|
||||
existing.priority = priority;
|
||||
} else if (existing.status === 'present' && !existing.objectUrl) {
|
||||
existing.priority = priority;
|
||||
enqueue(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = {
|
||||
key,
|
||||
candidate,
|
||||
box,
|
||||
priority,
|
||||
status: 'unknown',
|
||||
objectUrl: null,
|
||||
acquisitionTime: null,
|
||||
loading: false,
|
||||
controller: null,
|
||||
touched: 0,
|
||||
};
|
||||
touch(entry);
|
||||
entries.set(key, entry);
|
||||
enqueue(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Request the visible window of a strip in `thumbnailOrder` priority.
|
||||
* @param {Array<object>} candidates
|
||||
* @param {object} box
|
||||
* @param {{ focusIndex: number, firstVisible: number, lastVisible: number, extra?: number }} window
|
||||
*/
|
||||
requestOrdered(
|
||||
candidates,
|
||||
box,
|
||||
{ focusIndex, firstVisible, lastVisible, extra },
|
||||
) {
|
||||
const order = thumbnailOrder(
|
||||
focusIndex,
|
||||
firstVisible,
|
||||
lastVisible,
|
||||
candidates.length,
|
||||
extra,
|
||||
);
|
||||
order.forEach((index, priority) => {
|
||||
this.request(candidates[index], box, priority);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Abort every fetch in flight and forget every unsettled request. A
|
||||
* known day whose image was being fetched again keeps what it knows.
|
||||
*/
|
||||
cancelAll() {
|
||||
queue.length = 0;
|
||||
for (const entry of [...entries.values()]) {
|
||||
if (entry.loading) entry.controller?.abort();
|
||||
if (entry.status === 'unknown') entries.delete(entry.key);
|
||||
}
|
||||
},
|
||||
|
||||
/** Cancel everything and revoke every decoded thumbnail. */
|
||||
clear() {
|
||||
this.cancelAll();
|
||||
for (const entry of entries.values()) revokeImage(entry);
|
||||
entries.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Read a day's state. A read is not a use: it never changes residency.
|
||||
* @param {string} key Candidate key.
|
||||
* @returns {{ status: 'unknown'|'present'|'empty'|'error', objectUrl: string | null, acquisitionTime: string | null, loading: boolean }}
|
||||
*/
|
||||
get(key) {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return EMPTY;
|
||||
return {
|
||||
status: entry.status,
|
||||
objectUrl: entry.objectUrl,
|
||||
acquisitionTime: entry.acquisitionTime,
|
||||
loading: entry.loading,
|
||||
};
|
||||
},
|
||||
|
||||
/** @param {(key: string) => void} listener */
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
stats() {
|
||||
return {
|
||||
inFlight,
|
||||
queued: queue.length,
|
||||
decoded: [...entries.values()].filter((entry) => entry.objectUrl)
|
||||
.length,
|
||||
tracked: entries.size,
|
||||
};
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
this.clear();
|
||||
destroyed = true;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createThumbnailLoader } from './thumbnails.js';
|
||||
import { BOX, response, settle } from './testDoubles.mjs';
|
||||
|
||||
const candidate = (product, day) => ({
|
||||
key: `${product}:${day}`,
|
||||
product,
|
||||
day,
|
||||
});
|
||||
|
||||
/** A fetch whose responses the test releases, in any order. */
|
||||
function fakeFetch() {
|
||||
const calls = [];
|
||||
const impl = (url, { signal } = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||
);
|
||||
calls.push({ url, signal, resolve });
|
||||
});
|
||||
const respond = (index, { ok = true, present = 'true' } = {}) =>
|
||||
calls[index].resolve(
|
||||
response({
|
||||
ok,
|
||||
headers: {
|
||||
'Data-Present': present,
|
||||
'Acquisition-Time': '2026-09-18T17:12:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { impl, calls, respond };
|
||||
}
|
||||
|
||||
function fixture(options = {}) {
|
||||
const fetch = fakeFetch();
|
||||
const created = [];
|
||||
const revoked = [];
|
||||
const loader = createThumbnailLoader({
|
||||
fetchImpl: fetch.impl,
|
||||
createObjectUrl: () => {
|
||||
created.push(`blob:${created.length + 1}`);
|
||||
return created.at(-1);
|
||||
},
|
||||
revokeObjectUrl: (url) => revoked.push(url),
|
||||
...options,
|
||||
});
|
||||
return { loader, fetch, created, revoked };
|
||||
}
|
||||
|
||||
test('Data-Present decides the day: present with an object URL and sensing time, empty, or error', async () => {
|
||||
const { loader, fetch, created } = fixture();
|
||||
const changes = [];
|
||||
loader.subscribe((key) => changes.push(key));
|
||||
loader.request(candidate('S30', '2026-09-18'), BOX, 0);
|
||||
loader.request(candidate('L30', '2026-09-16'), BOX, 1);
|
||||
loader.request(candidate('VIIRS', '2026-09-15'), BOX, 2);
|
||||
assert.equal(loader.get('S30:2026-09-18').loading, true);
|
||||
assert.match(
|
||||
fetch.calls[0].url,
|
||||
/wvs\.earthdata\.nasa\.gov.*HLS_S30.*TIME=2026-09-18.*WIDTH=256&HEIGHT=256/,
|
||||
);
|
||||
fetch.respond(0);
|
||||
fetch.respond(1, { present: 'false' });
|
||||
fetch.respond(2, { ok: false });
|
||||
await settle();
|
||||
assert.deepEqual(loader.get('S30:2026-09-18'), {
|
||||
status: 'present',
|
||||
objectUrl: 'blob:1',
|
||||
acquisitionTime: '2026-09-18T17:12:00Z',
|
||||
loading: false,
|
||||
});
|
||||
assert.equal(loader.get('L30:2026-09-16').status, 'empty');
|
||||
assert.equal(loader.get('VIIRS:2026-09-15').status, 'error');
|
||||
assert.deepEqual(created, ['blob:1']);
|
||||
assert.deepEqual(changes.sort(), [
|
||||
'L30:2026-09-16',
|
||||
'S30:2026-09-18',
|
||||
'VIIRS:2026-09-15',
|
||||
]);
|
||||
assert.equal(loader.get('nope').status, 'unknown');
|
||||
});
|
||||
|
||||
test('at most maxInFlight fetches run and the queue drains by priority', async () => {
|
||||
const { loader, fetch } = fixture({ maxInFlight: 2 });
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 5);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-13'), BOX, 3);
|
||||
// The pump is eager: the first two arrivals start at once, the rest queue.
|
||||
assert.equal(fetch.calls.length, 2);
|
||||
assert.match(fetch.calls[0].url, /TIME=2026-09-10/);
|
||||
assert.match(fetch.calls[1].url, /TIME=2026-09-11/);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 2,
|
||||
queued: 2,
|
||||
decoded: 0,
|
||||
tracked: 4,
|
||||
});
|
||||
fetch.respond(0);
|
||||
await settle();
|
||||
assert.equal(fetch.calls.length, 3);
|
||||
assert.match(fetch.calls[2].url, /TIME=2026-09-12/, 'priority 0 before 3');
|
||||
// A repeat request for a queued key just re-prioritises it.
|
||||
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
|
||||
assert.equal(loader.stats().tracked, 4);
|
||||
fetch.respond(1);
|
||||
await settle();
|
||||
assert.equal(fetch.calls.length, 4);
|
||||
assert.match(fetch.calls[3].url, /TIME=2026-09-13/);
|
||||
});
|
||||
|
||||
test('requestOrdered loads the focused card first, then outward, then the margins', () => {
|
||||
const { loader, fetch } = fixture({ maxInFlight: 10 });
|
||||
const candidates = Array.from({ length: 8 }, (_, i) =>
|
||||
candidate('S30', `2026-09-${String(10 + i).padStart(2, '0')}`),
|
||||
);
|
||||
loader.requestOrdered(candidates, BOX, {
|
||||
focusIndex: 3,
|
||||
firstVisible: 2,
|
||||
lastVisible: 5,
|
||||
extra: 1,
|
||||
});
|
||||
const days = fetch.calls.map(
|
||||
(call) => /TIME=2026-09-(\d\d)/.exec(call.url)[1],
|
||||
);
|
||||
assert.deepEqual(days, ['13', '14', '12', '15', '16', '11']);
|
||||
});
|
||||
|
||||
test('decoded thumbnails are evicted least-recently-used and revoked; a read is not a use and an evicted day keeps what it learned', async () => {
|
||||
const { loader, fetch, revoked } = fixture({
|
||||
maxInFlight: 10,
|
||||
maxDecoded: 2,
|
||||
});
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 2);
|
||||
fetch.respond(0);
|
||||
fetch.respond(1);
|
||||
await settle();
|
||||
assert.equal(loader.stats().decoded, 2);
|
||||
loader.get('S30:2026-09-10'); // reading the oldest does not make it recent
|
||||
fetch.respond(2);
|
||||
await settle();
|
||||
assert.equal(loader.stats().decoded, 2);
|
||||
assert.deepEqual(revoked, ['blob:1']);
|
||||
const evicted = loader.get('S30:2026-09-10');
|
||||
assert.equal(evicted.status, 'present', 'availability survives eviction');
|
||||
assert.equal(evicted.objectUrl, null);
|
||||
assert.equal(evicted.acquisitionTime, '2026-09-18T17:12:00Z');
|
||||
assert.equal(loader.stats().tracked, 3, 'the entry is kept');
|
||||
assert.equal(loader.get('S30:2026-09-11').objectUrl, 'blob:2');
|
||||
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
|
||||
// A repeat request IS a use, and re-fetches an evicted day without ever
|
||||
// reporting it unknown; 11 is now the LRU and goes when 10 comes back.
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
assert.equal(fetch.calls.length, 4, 'an evicted day is fetched again');
|
||||
assert.equal(loader.get('S30:2026-09-10').status, 'present');
|
||||
assert.equal(loader.get('S30:2026-09-10').loading, true);
|
||||
fetch.respond(3);
|
||||
await settle();
|
||||
assert.equal(loader.get('S30:2026-09-10').objectUrl, 'blob:4');
|
||||
assert.deepEqual(revoked, ['blob:1', 'blob:2']);
|
||||
assert.equal(loader.get('S30:2026-09-11').status, 'present');
|
||||
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
|
||||
// A day that already holds an image is not fetched twice.
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
assert.equal(fetch.calls.length, 4);
|
||||
});
|
||||
|
||||
test('reading every card in strip order (a snapshot) never decides who is evicted; evicted days stay known and come back on request', async () => {
|
||||
const { loader, fetch, revoked } = fixture({
|
||||
maxInFlight: 20,
|
||||
maxDecoded: 9,
|
||||
});
|
||||
const candidates = Array.from({ length: 20 }, (_, i) =>
|
||||
candidate('S30', `2026-09-${String(i + 1).padStart(2, '0')}`),
|
||||
);
|
||||
candidates.forEach((c, i) => loader.request(c, BOX, i));
|
||||
assert.equal(fetch.calls.length, 20);
|
||||
// Decode the strip back to front, leaving the newest card in flight.
|
||||
for (let i = 19; i >= 1; i -= 1) fetch.respond(i);
|
||||
await settle();
|
||||
const holders = () =>
|
||||
candidates.filter((c) => loader.get(c.key).objectUrl).map((c) => c.key);
|
||||
assert.equal(loader.stats().decoded, 9);
|
||||
assert.equal(loader.stats().tracked, 20);
|
||||
assert.deepEqual(
|
||||
holders(),
|
||||
candidates.slice(1, 10).map((c) => c.key),
|
||||
'the nine most recently decoded',
|
||||
);
|
||||
// Sweep every card in strip order, several times, as getSnapshot does.
|
||||
for (let pass = 0; pass < 3; pass += 1)
|
||||
for (const c of candidates) loader.get(c.key);
|
||||
assert.deepEqual(
|
||||
holders(),
|
||||
candidates.slice(1, 10).map((c) => c.key),
|
||||
);
|
||||
// The last decode evicts the least recently DECODED card, not the first
|
||||
// card the sweep happened to read.
|
||||
fetch.respond(0);
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
holders(),
|
||||
candidates.slice(0, 9).map((c) => c.key),
|
||||
);
|
||||
assert.equal(revoked.length, 11);
|
||||
for (const c of candidates) {
|
||||
const entry = loader.get(c.key);
|
||||
assert.equal(entry.status, 'present', `${c.key} stays present`);
|
||||
assert.equal(entry.acquisitionTime, '2026-09-18T17:12:00Z');
|
||||
assert.equal(entry.loading, false);
|
||||
}
|
||||
// Re-requesting an evicted day fetches it again and it holds an image.
|
||||
const evictedKey = candidates[15].key;
|
||||
assert.equal(loader.get(evictedKey).objectUrl, null);
|
||||
loader.request(candidates[15], BOX, 0);
|
||||
assert.equal(fetch.calls.length, 21);
|
||||
assert.equal(loader.get(evictedKey).status, 'present');
|
||||
fetch.respond(20);
|
||||
await settle();
|
||||
assert.ok(loader.get(evictedKey).objectUrl);
|
||||
assert.equal(loader.stats().decoded, 9);
|
||||
assert.equal(loader.stats().tracked, 20);
|
||||
});
|
||||
|
||||
test('cancelAll aborts in-flight fetches and a late settle never leaks an object URL', async () => {
|
||||
const { loader, fetch, created, revoked } = fixture({ maxInFlight: 1 });
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
const signal = fetch.calls[0].signal;
|
||||
loader.cancelAll();
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 0,
|
||||
decoded: 0,
|
||||
tracked: 0,
|
||||
});
|
||||
await settle();
|
||||
assert.equal(loader.stats().inFlight, 0);
|
||||
assert.deepEqual(created, []);
|
||||
assert.deepEqual(revoked, []);
|
||||
assert.equal(loader.get('S30:2026-09-10').status, 'unknown');
|
||||
});
|
||||
|
||||
test('clear and destroy revoke every resident thumbnail', async () => {
|
||||
const { loader, fetch, revoked } = fixture({ maxInFlight: 10 });
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
|
||||
fetch.respond(0);
|
||||
fetch.respond(1);
|
||||
await settle();
|
||||
loader.clear();
|
||||
assert.deepEqual(revoked.sort(), ['blob:1', 'blob:2']);
|
||||
assert.equal(loader.stats().tracked, 0);
|
||||
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
|
||||
fetch.respond(2);
|
||||
await settle();
|
||||
loader.destroy();
|
||||
assert.equal(revoked.length, 3);
|
||||
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
|
||||
assert.equal(fetch.calls.length, 3, 'a destroyed loader fetches nothing');
|
||||
});
|
||||
|
||||
test('a request queued behind an aborted fetch still starts once the abort settles', async () => {
|
||||
// A response that arrives after the abort (the fetch ignored the signal).
|
||||
const calls = [];
|
||||
const fetchImpl = (url, { signal } = {}) =>
|
||||
new Promise((resolve) => calls.push({ url, signal, resolve }));
|
||||
const loader = createThumbnailLoader({
|
||||
fetchImpl,
|
||||
maxInFlight: 1,
|
||||
createObjectUrl: () => 'blob:x',
|
||||
revokeObjectUrl: () => {},
|
||||
});
|
||||
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
loader.clear();
|
||||
loader.request(candidate('S30', '2026-09-11'), BOX, 0);
|
||||
assert.equal(calls[0].signal.aborted, true);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 1,
|
||||
decoded: 0,
|
||||
tracked: 1,
|
||||
});
|
||||
calls[0].resolve({
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
blob: async () => ({}),
|
||||
});
|
||||
await settle();
|
||||
assert.equal(calls.length, 2, 'the replacement request was pumped');
|
||||
assert.match(calls[1].url, /TIME=2026-09-11/);
|
||||
assert.deepEqual(loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 0,
|
||||
decoded: 0,
|
||||
tracked: 1,
|
||||
});
|
||||
|
||||
// The same sequence when the fetch rejects with AbortError instead.
|
||||
const rejecting = fixture({ maxInFlight: 1 });
|
||||
rejecting.loader.request(candidate('S30', '2026-09-10'), BOX, 0);
|
||||
rejecting.loader.clear();
|
||||
rejecting.loader.request(candidate('S30', '2026-09-11'), BOX, 0);
|
||||
await settle();
|
||||
assert.equal(rejecting.fetch.calls.length, 2);
|
||||
assert.deepEqual(rejecting.loader.stats(), {
|
||||
inFlight: 1,
|
||||
queued: 0,
|
||||
decoded: 0,
|
||||
tracked: 1,
|
||||
});
|
||||
});
|
||||
+75
-3
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -138,6 +138,35 @@ test('visual parameters, explicit empty layers, and panel state are v2-only', ()
|
||||
assert.equal(legacy.panelState, null);
|
||||
});
|
||||
|
||||
// The panel chrome shares every rail panel, so the link registry must know
|
||||
// each one: a collapsed Recent Imagery panel used to restore expanded because
|
||||
// the encoder had no token for it.
|
||||
test('a collapsed Recent Imagery panel survives the share-link round trip beside the older panels', () => {
|
||||
const manager = makeManager();
|
||||
manager.setPanelStateProvider(() => ({ specs: [
|
||||
{ id: 'cctv-panel', collapsed: false },
|
||||
{ id: 'recent-imagery-panel', collapsed: true },
|
||||
{ id: 'global-context-panel', collapsed: true },
|
||||
] }));
|
||||
clearTimeout(manager._debounceTimer);
|
||||
manager._updateHash();
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
assert.equal(params.get('ui'), 'v.c.0_i.c.1_g.c.1');
|
||||
const restored = makeManager(`#v=2&lat=10&lon=20&ui=${params.get('ui')}`)
|
||||
.parseInitialHash();
|
||||
assert.deepEqual(restored.panelState, { specs: [
|
||||
{ id: 'cctv-panel', collapsed: false, pinned: null },
|
||||
{ id: 'recent-imagery-panel', collapsed: true, pinned: null },
|
||||
{ id: 'global-context-panel', collapsed: true, pinned: null },
|
||||
] });
|
||||
// A link written before the token existed decodes exactly as it did.
|
||||
const older = makeManager('#v=2&lat=10&lon=20&ui=v.c.1_g.c.0').parseInitialHash();
|
||||
assert.deepEqual(older.panelState, { specs: [
|
||||
{ id: 'cctv-panel', collapsed: true, pinned: null },
|
||||
{ id: 'global-context-panel', collapsed: false, pinned: null },
|
||||
] });
|
||||
});
|
||||
|
||||
test('camera-only, partial, and malformed panel shares remain valid incoming state', () => {
|
||||
const cameraOnly = makeManager('#lat=10&lon=20').parseInitialHash();
|
||||
assert.ok(cameraOnly);
|
||||
|
||||
@@ -40,6 +40,7 @@ const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
|
||||
{ id: 'location-bar', token: 'l', pinnable: true },
|
||||
{ id: 'data-panel', token: 'd', pinnable: false },
|
||||
{ id: 'cctv-panel', token: 'v', pinnable: false },
|
||||
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
|
||||
{ id: 'radio-panel', token: 'r', pinnable: false },
|
||||
{ id: 'scene-panel', token: 's', pinnable: false },
|
||||
{ id: 'global-context-panel', token: 'g', pinnable: false },
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Box selection for the Recent Imagery layer: press on the world, drag, and
|
||||
* release to hand the layer a degrees box. Follows the draw tool's ownership
|
||||
* rules — the pointer is claimed for the session, the viewer's own click
|
||||
* actions are borrowed and given back, and `destroy()` returns everything —
|
||||
* and freezes the camera only while a drag is in progress.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { validateBox } from '../layers/recentImagery/model.js';
|
||||
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
|
||||
|
||||
/**
|
||||
* Wire the box tool.
|
||||
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
|
||||
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
|
||||
* the key (it is still consumed) and leaves the tool armed.
|
||||
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
|
||||
*/
|
||||
export function initImageryBoxTool({
|
||||
viewer,
|
||||
onBox,
|
||||
onCancel,
|
||||
onActive,
|
||||
onEscape,
|
||||
cesium = Cesium,
|
||||
pickWorld = pickWorldFromScreen,
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
if (!viewer?.scene || typeof onBox !== 'function') return null;
|
||||
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
/** Drag corners in degrees; `anchor` is null while no drag is live. */
|
||||
let anchor = null;
|
||||
let corner = null;
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedSingleClick = null;
|
||||
let savedDoubleClick = null;
|
||||
let savedCameraInputs = null;
|
||||
|
||||
const dragBox = () =>
|
||||
anchor && {
|
||||
west: Math.min(anchor.lon, corner.lon),
|
||||
south: Math.min(anchor.lat, corner.lat),
|
||||
east: Math.max(anchor.lon, corner.lon),
|
||||
north: Math.max(anchor.lat, corner.lat),
|
||||
};
|
||||
|
||||
const dataSource = new cesium.CustomDataSource(
|
||||
'gev-recent-imagery-box-preview',
|
||||
);
|
||||
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
|
||||
() => null,
|
||||
);
|
||||
const stroke = cesium.Color.fromCssColorString('#8be9ff');
|
||||
const previewRectangle = () => {
|
||||
const box = dragBox();
|
||||
return box
|
||||
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
|
||||
: undefined;
|
||||
};
|
||||
const previewOutline = () => {
|
||||
const box = dragBox();
|
||||
if (!box) return [];
|
||||
return cesium.Cartesian3.fromDegreesArray([
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
box.west,
|
||||
box.north,
|
||||
box.west,
|
||||
box.south,
|
||||
]);
|
||||
};
|
||||
const fill = dataSource.entities.add({
|
||||
show: false,
|
||||
rectangle: {
|
||||
coordinates: new cesium.CallbackProperty(previewRectangle, false),
|
||||
material: stroke.withAlpha(0.12),
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
// A rectangle graphic cannot draw a dashed edge on the ground, so the
|
||||
// outline is its own draped polyline traced round the same corners.
|
||||
const outline = dataSource.entities.add({
|
||||
show: false,
|
||||
polyline: {
|
||||
positions: new cesium.CallbackProperty(previewOutline, false),
|
||||
width: 2,
|
||||
clampToGround: true,
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
material: new cesium.PolylineDashMaterialProperty({
|
||||
color: stroke.withAlpha(0.95),
|
||||
dashLength: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const requestRender = () => viewer.scene.requestRender?.();
|
||||
const setPreview = (visible) => {
|
||||
fill.show = visible;
|
||||
outline.show = visible;
|
||||
requestRender();
|
||||
};
|
||||
const worldAt = (position) => {
|
||||
if (!position) return null;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const w = canvas?.clientWidth || canvas?.width || 1;
|
||||
const h = canvas?.clientHeight || canvas?.height || 1;
|
||||
const point = pickWorld(viewer, position.x / w, position.y / h);
|
||||
const lon = Number(point?.lon);
|
||||
const lat = Number(point?.lat);
|
||||
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
|
||||
};
|
||||
const cameraController = () => viewer.scene.screenSpaceCameraController;
|
||||
|
||||
function freezeCamera() {
|
||||
const controller = cameraController();
|
||||
if (!controller || savedCameraInputs !== null) return;
|
||||
savedCameraInputs = controller.enableInputs;
|
||||
controller.enableInputs = false;
|
||||
}
|
||||
function thawCamera() {
|
||||
const controller = cameraController();
|
||||
if (controller && savedCameraInputs !== null)
|
||||
controller.enableInputs = savedCameraInputs;
|
||||
savedCameraInputs = null;
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
anchor = null;
|
||||
corner = null;
|
||||
thawCamera();
|
||||
setPreview(false);
|
||||
}
|
||||
|
||||
const onDown = (event) => {
|
||||
if (!active || destroyed || anchor) return;
|
||||
const point = worldAt(event.position);
|
||||
if (!point) {
|
||||
onCancel?.('sky', 'Press on the ground, not the sky');
|
||||
return;
|
||||
}
|
||||
anchor = point;
|
||||
corner = point;
|
||||
freezeCamera();
|
||||
setPreview(true);
|
||||
};
|
||||
const onMove = (event) => {
|
||||
const point = anchor && !destroyed && worldAt(event.endPosition);
|
||||
if (!point) return;
|
||||
corner = point;
|
||||
requestRender();
|
||||
};
|
||||
const onUp = (event) => {
|
||||
if (!anchor || destroyed) return;
|
||||
corner = worldAt(event.position) || corner;
|
||||
const box = dragBox();
|
||||
const result = validateBox(box);
|
||||
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
|
||||
endDrag();
|
||||
setActive(false);
|
||||
if (result.ok) onBox(result.box);
|
||||
else onCancel?.(result.reason, result.message, box);
|
||||
};
|
||||
// Escape is taken from a document capture listener, ahead of every other
|
||||
// handler, so the interceptor runs first: when it claims the key (the
|
||||
// panel exits a live comparison), the tool stays armed and the key still
|
||||
// goes no further; otherwise Escape cancels the session.
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || event.key !== 'Escape') return;
|
||||
event.preventDefault?.();
|
||||
event.stopImmediatePropagation?.();
|
||||
if (onEscape?.() === true) return;
|
||||
cancel('escape');
|
||||
};
|
||||
|
||||
function bindScene() {
|
||||
if (handler) return;
|
||||
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock) {
|
||||
savedSingleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
||||
savedDoubleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null;
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
}
|
||||
documentRef?.addEventListener?.('keydown', onKey, true);
|
||||
documentRef?.body?.classList?.add?.('gev-imagery-box');
|
||||
}
|
||||
|
||||
function releaseScene() {
|
||||
if (handler) {
|
||||
handler.destroy();
|
||||
handler = null;
|
||||
}
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock && savedSingleClick) {
|
||||
stock.setInputAction(
|
||||
savedSingleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
}
|
||||
if (stock && savedDoubleClick) {
|
||||
stock.setInputAction(
|
||||
savedDoubleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
||||
);
|
||||
}
|
||||
savedSingleClick = null;
|
||||
savedDoubleClick = null;
|
||||
documentRef?.removeEventListener?.('keydown', onKey, true);
|
||||
documentRef?.body?.classList?.remove?.('gev-imagery-box');
|
||||
}
|
||||
|
||||
function setActive(next) {
|
||||
if (next === active) return active;
|
||||
if (next) {
|
||||
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
|
||||
if (!lease) {
|
||||
onCancel?.(
|
||||
'pointer-busy',
|
||||
`${pointerOwner()} is using the pointer — close it first`,
|
||||
);
|
||||
return active;
|
||||
}
|
||||
active = true;
|
||||
bindScene();
|
||||
} else {
|
||||
if (anchor) endDrag();
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
active = false;
|
||||
}
|
||||
onActive?.(active);
|
||||
return active;
|
||||
}
|
||||
|
||||
function cancel(reason = 'cancel') {
|
||||
if (!active || destroyed) return;
|
||||
setActive(false);
|
||||
onCancel?.(reason);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Begin a box session. False when the pointer is held elsewhere. */
|
||||
start() {
|
||||
return !destroyed && setActive(true);
|
||||
},
|
||||
cancel,
|
||||
isActive() {
|
||||
return active;
|
||||
},
|
||||
/** Returns everything; resolves once the preview is detached. */
|
||||
destroy() {
|
||||
if (destroyed) return attaching;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
thawCamera();
|
||||
dataSource.entities.removeAll();
|
||||
attaching = attaching.then(() => {
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
/* viewer already disposed */
|
||||
}
|
||||
});
|
||||
return attaching;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
claimPointer,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
resetPointerOwnership,
|
||||
} from '../data/inputOwnership.js';
|
||||
import {
|
||||
IMAGERY_BOX_POINTER_OWNER,
|
||||
initImageryBoxTool,
|
||||
} from './imageryBoxTool.js';
|
||||
import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs';
|
||||
|
||||
function fixture(options = {}) {
|
||||
resetPointerOwnership();
|
||||
const fakes = boxToolFakes();
|
||||
const boxes = [];
|
||||
const cancels = [];
|
||||
const actives = [];
|
||||
const tool = initImageryBoxTool({
|
||||
viewer: fakes.viewer,
|
||||
cesium: fakes.cesium,
|
||||
pickWorld: fakes.pickWorld,
|
||||
documentRef: fakes.documentRef,
|
||||
onBox: (box) => boxes.push(box),
|
||||
onCancel: (reason, message) => cancels.push([reason, message]),
|
||||
onActive: (active) => actives.push(active),
|
||||
...options,
|
||||
});
|
||||
const camera = fakes.viewer.scene.screenSpaceCameraController;
|
||||
const stockAction = (type) =>
|
||||
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]);
|
||||
const preview = () =>
|
||||
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
|
||||
return {
|
||||
...fakes,
|
||||
tool,
|
||||
boxes,
|
||||
cancels,
|
||||
actives,
|
||||
camera,
|
||||
stockAction,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.tool.start(), true);
|
||||
assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), undefined);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined);
|
||||
assert.ok(f.documentRef.classes.has('gev-imagery-box'));
|
||||
assert.equal(f.camera.enableInputs, true, 'not frozen before the press');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.camera.enableInputs, false);
|
||||
assert.equal(f.preview(), true);
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } });
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored
|
||||
f.fire('LEFT_UP', { position: { x: 500, y: 250 } });
|
||||
assert.equal(f.boxes.length, 1);
|
||||
const { west, south, east, north } = f.boxes[0];
|
||||
assert.deepEqual(
|
||||
[west, south, east, north].map((v) => Math.round(v * 1e6) / 1e6),
|
||||
[-97.85, 30.3, -97.8, 30.35],
|
||||
);
|
||||
assert.deepEqual(f.cancels, []);
|
||||
assert.equal(f.tool.isActive(), false);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble);
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
assert.equal(f.preview(), false);
|
||||
assert.equal(f.documentRef.classes.has('gev-imagery-box'), false);
|
||||
assert.equal(f.documentRef.listeners.length, 0);
|
||||
assert.equal(f.handler(), null);
|
||||
assert.deepEqual(f.actives, [true, false]);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a sky press, a click without a drag and Escape each say why', async () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } });
|
||||
assert.deepEqual(f.cancels.at(-1), [
|
||||
'sky',
|
||||
'Press on the ground, not the sky',
|
||||
]);
|
||||
assert.equal(f.tool.isActive(), true, 'a sky press keeps the session');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
f.fire('LEFT_UP', { position: { x: 250, y: 125 } });
|
||||
assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']);
|
||||
f.tool.start();
|
||||
assert.equal(f.key('Escape').prevented, true);
|
||||
assert.deepEqual(f.cancels.at(-1), ['escape', undefined]);
|
||||
assert.equal(pointerOwner(), null);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => {
|
||||
let claim = true;
|
||||
let asked = 0;
|
||||
const f = fixture({
|
||||
onEscape: () => {
|
||||
asked += 1;
|
||||
return claim;
|
||||
},
|
||||
});
|
||||
f.tool.start();
|
||||
const first = f.key('Escape');
|
||||
assert.equal(asked, 1);
|
||||
assert.equal(
|
||||
first.prevented && first.stopped,
|
||||
true,
|
||||
'the key goes no further',
|
||||
);
|
||||
assert.equal(f.tool.isActive(), true);
|
||||
claim = false;
|
||||
f.key('Escape');
|
||||
assert.deepEqual(f.cancels, [['escape', undefined]]);
|
||||
f.tool.start();
|
||||
f.key('Enter');
|
||||
assert.equal(asked, 2, 'other keys never reach the interceptor');
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a held pointer refuses the session and nothing is borrowed', async () => {
|
||||
const f = fixture();
|
||||
const lease = claimPointer('draw');
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.deepEqual(f.cancels, [
|
||||
['pointer-busy', 'draw is using the pointer — close it first'],
|
||||
]);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.handler(), null);
|
||||
releasePointer(lease);
|
||||
assert.equal(f.tool.start(), true);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.viewer.dataSources.sources.length, 1);
|
||||
await f.tool.destroy();
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.viewer.dataSources.sources.length, 0);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
|
||||
|
||||
function clampUnit(value) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
function defaultViewportWidth() {
|
||||
return (
|
||||
Number(document.documentElement?.clientWidth) ||
|
||||
Number(globalThis.window?.innerWidth) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function defaultValueText(beforePercent, afterPercent) {
|
||||
return `A ${beforePercent} percent, B ${afterPercent} percent`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split divider for before/after imagery comparison.
|
||||
*
|
||||
* Owns the DOM line + handle, pointer capture, keyboard, ARIA, a CSS custom
|
||||
* property and scene.splitPosition. `setValue` is the owner's own write and
|
||||
* never echoes through `onChange`; `onChange` reports pointer and keyboard
|
||||
* changes so the owner can mirror them, and `requestRender` fires whenever
|
||||
* the clamped value actually changes. `destroy` is idempotent: it removes the
|
||||
* listeners and line, clears the CSS property and restores the scene split
|
||||
* captured at creation once.
|
||||
*/
|
||||
export function createImagerySplit({
|
||||
scene,
|
||||
parent = document.body,
|
||||
initialValue = 0.5,
|
||||
id,
|
||||
handleClass,
|
||||
cssTarget = document.documentElement,
|
||||
cssProperty,
|
||||
beforeLabel = 'A',
|
||||
afterLabel = 'B',
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
ariaLabel,
|
||||
formatValueText = defaultValueText,
|
||||
getViewportWidth = defaultViewportWidth,
|
||||
onChange,
|
||||
requestRender,
|
||||
} = {}) {
|
||||
const restoreValue = scene ? (scene.splitPosition ?? null) : null;
|
||||
const line = document.createElement('div');
|
||||
if (id) line.id = id;
|
||||
const before = document.createElement('span');
|
||||
const after = document.createElement('span');
|
||||
const handle = document.createElement('button');
|
||||
before.className = 'before';
|
||||
after.className = 'after';
|
||||
before.textContent = beforeLabel;
|
||||
if (beforeTitle != null) before.title = beforeTitle;
|
||||
after.textContent = afterLabel;
|
||||
if (afterTitle != null) after.title = afterTitle;
|
||||
handle.type = 'button';
|
||||
if (handleClass) handle.className = handleClass;
|
||||
handle.setAttribute('role', 'slider');
|
||||
if (ariaLabel != null) handle.setAttribute('aria-label', ariaLabel);
|
||||
handle.setAttribute('aria-valuemin', '0');
|
||||
handle.setAttribute('aria-valuemax', '100');
|
||||
handle.setAttribute('aria-orientation', 'horizontal');
|
||||
handle.textContent = '↔';
|
||||
|
||||
let value = null;
|
||||
let cssValue = null;
|
||||
let dragging = false;
|
||||
let pointerId = null;
|
||||
let destroyed = false;
|
||||
|
||||
function apply(next, { notify = false, render = true } = {}) {
|
||||
const clamped = clampUnit(next);
|
||||
const changed = clamped !== value;
|
||||
value = clamped;
|
||||
if (scene && scene.splitPosition !== value) scene.splitPosition = value;
|
||||
const css = `${value * 100}%`;
|
||||
if (cssProperty && cssValue !== css) {
|
||||
cssTarget.style.setProperty(cssProperty, css);
|
||||
cssValue = css;
|
||||
}
|
||||
const beforePercent = Math.round(value * 100);
|
||||
handle.setAttribute('aria-valuenow', String(beforePercent));
|
||||
handle.setAttribute(
|
||||
'aria-valuetext',
|
||||
formatValueText(beforePercent, 100 - beforePercent),
|
||||
);
|
||||
if (changed && render) requestRender?.();
|
||||
if (changed && notify) onChange?.(value);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function setFromClientX(clientX) {
|
||||
const viewportWidth = Number(getViewportWidth()) || 0;
|
||||
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
|
||||
apply(Number(clientX) / viewportWidth, { notify: true });
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
event.preventDefault?.();
|
||||
dragging = true;
|
||||
pointerId = event.pointerId ?? null;
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
setFromClientX(event.clientX);
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
event.preventDefault?.();
|
||||
setFromClientX(event.clientX);
|
||||
}
|
||||
|
||||
function finishPointerDrag(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
handle.releasePointerCapture?.(event.pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (!HANDLE_KEYS.has(event.key)) return;
|
||||
event.preventDefault?.();
|
||||
const step = event.shiftKey ? 0.05 : 0.01;
|
||||
if (event.key === 'Home') apply(0, { notify: true });
|
||||
else if (event.key === 'End') apply(1, { notify: true });
|
||||
else
|
||||
apply(value + (event.key === 'ArrowRight' ? step : -step), {
|
||||
notify: true,
|
||||
});
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
['pointerdown', handlePointerDown],
|
||||
['pointermove', handlePointerMove],
|
||||
['pointerup', finishPointerDrag],
|
||||
['pointercancel', finishPointerDrag],
|
||||
['keydown', handleKeyDown],
|
||||
];
|
||||
for (const [type, listener] of listeners)
|
||||
handle.addEventListener(type, listener);
|
||||
line.append(before, handle, after);
|
||||
parent.appendChild(line);
|
||||
apply(initialValue, { render: false });
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return value;
|
||||
},
|
||||
setValue(next) {
|
||||
if (destroyed) return false;
|
||||
return apply(next);
|
||||
},
|
||||
setVisible(visible) {
|
||||
line.hidden = !visible;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (dragging && pointerId != null)
|
||||
handle.releasePointerCapture?.(pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
for (const [type, listener] of listeners)
|
||||
handle.removeEventListener?.(type, listener);
|
||||
line.remove();
|
||||
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
|
||||
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.removed = false;
|
||||
this.captured = [];
|
||||
this.released = [];
|
||||
this._attributes = new Map();
|
||||
this._listeners = new Map();
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this._attributes.set(name, value);
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this._attributes.get(name) ?? null;
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
this._listeners.set(type, handler);
|
||||
}
|
||||
removeEventListener(type, handler) {
|
||||
if (this._listeners.get(type) === handler) this._listeners.delete(type);
|
||||
}
|
||||
dispatch(type, event = {}) {
|
||||
return this._listeners.get(type)?.(event);
|
||||
}
|
||||
setPointerCapture(id) {
|
||||
this.captured.push(id);
|
||||
}
|
||||
releasePointerCapture(id) {
|
||||
this.released.push(id);
|
||||
}
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
remove() {
|
||||
this.removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
|
||||
const body = new FakeElement('body');
|
||||
const cssCalls = [];
|
||||
const changes = [];
|
||||
let renders = 0;
|
||||
const scene = { splitPosition };
|
||||
const cssTarget = {
|
||||
style: {
|
||||
setProperty(name, value) {
|
||||
cssCalls.push(['set', name, value]);
|
||||
},
|
||||
removeProperty(name) {
|
||||
cssCalls.push(['remove', name]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
body,
|
||||
createElement: (tagName) => new FakeElement(tagName),
|
||||
documentElement: cssTarget,
|
||||
};
|
||||
const split = createImagerySplit({
|
||||
scene,
|
||||
parent: body,
|
||||
initialValue,
|
||||
id: 'split-line',
|
||||
handleClass: 'split-handle',
|
||||
cssTarget,
|
||||
cssProperty: '--split',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
getViewportWidth: () => 1000,
|
||||
onChange: (value) => changes.push(value),
|
||||
requestRender: () => {
|
||||
renders += 1;
|
||||
},
|
||||
});
|
||||
globalThis.document = previous;
|
||||
const line = body.children[0];
|
||||
const handle = line.children.find((child) => child.tagName === 'button');
|
||||
return {
|
||||
split,
|
||||
scene,
|
||||
body,
|
||||
line,
|
||||
handle,
|
||||
cssCalls,
|
||||
changes,
|
||||
renders: () => renders,
|
||||
};
|
||||
}
|
||||
|
||||
test('the split builds its line and applies the initial value without notifying', () => {
|
||||
const env = fixture({ initialValue: 0.62 });
|
||||
assert.equal(env.line.id, 'split-line');
|
||||
assert.equal(env.line.children[0].textContent, 'A');
|
||||
assert.equal(env.line.children[0].title, 'Before');
|
||||
assert.equal(env.line.children[2].textContent, 'B');
|
||||
assert.equal(env.line.children[2].title, 'After');
|
||||
assert.equal(env.handle.className, 'split-handle');
|
||||
assert.equal(env.handle.getAttribute('role'), 'slider');
|
||||
assert.equal(env.handle.getAttribute('aria-label'), 'Divider');
|
||||
assert.equal(env.handle.getAttribute('aria-valuenow'), '62');
|
||||
assert.equal(
|
||||
env.handle.getAttribute('aria-valuetext'),
|
||||
'A 62 percent, B 38 percent',
|
||||
);
|
||||
assert.equal(env.scene.splitPosition, 0.62);
|
||||
assert.deepEqual(env.cssCalls, [['set', '--split', '62%']]);
|
||||
assert.deepEqual(env.changes, []);
|
||||
assert.equal(env.renders(), 0);
|
||||
assert.equal(env.split.getValue(), 0.62);
|
||||
});
|
||||
|
||||
test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End', () => {
|
||||
const env = fixture();
|
||||
const key = (key, shiftKey = false) => {
|
||||
let prevented = false;
|
||||
env.handle.dispatch('keydown', {
|
||||
key,
|
||||
shiftKey,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
assert.equal(key('ArrowRight'), true);
|
||||
assert.equal(env.scene.splitPosition, 0.51);
|
||||
assert.equal(key('ArrowLeft', true), true);
|
||||
assert.equal(env.scene.splitPosition, 0.46);
|
||||
assert.equal(key('Home'), true);
|
||||
assert.equal(env.scene.splitPosition, 0);
|
||||
assert.equal(env.handle.getAttribute('aria-valuenow'), '0');
|
||||
assert.equal(key('End'), true);
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.equal(env.handle.getAttribute('aria-valuenow'), '100');
|
||||
assert.equal(key('Enter'), false, 'other keys are ignored and not prevented');
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.deepEqual(env.changes, [0.51, 0.46, 0, 1]);
|
||||
});
|
||||
|
||||
test('values clamp to the unit range and only real changes notify or render', () => {
|
||||
const env = fixture();
|
||||
assert.equal(env.split.setValue(1.4), true);
|
||||
assert.equal(env.split.getValue(), 1);
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.equal(env.split.setValue(1), false);
|
||||
assert.equal(env.split.setValue(-3), true);
|
||||
assert.equal(env.split.getValue(), 0);
|
||||
assert.equal(
|
||||
env.split.setValue('nonsense'),
|
||||
false,
|
||||
'non-numeric input clamps to 0',
|
||||
);
|
||||
assert.equal(env.renders(), 2, 'render only when the clamped value moved');
|
||||
assert.deepEqual(
|
||||
env.changes,
|
||||
[],
|
||||
'setValue is the owner writing; it never echoes',
|
||||
);
|
||||
env.handle.dispatch('keydown', { key: 'Home', preventDefault() {} });
|
||||
env.handle.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} });
|
||||
assert.deepEqual(
|
||||
env.changes,
|
||||
[],
|
||||
'interaction that cannot move the value stays silent',
|
||||
);
|
||||
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
assert.deepEqual(env.changes, [1]);
|
||||
assert.equal(env.renders(), 3);
|
||||
assert.deepEqual(env.cssCalls, [
|
||||
['set', '--split', '50%'],
|
||||
['set', '--split', '100%'],
|
||||
['set', '--split', '0%'],
|
||||
['set', '--split', '100%'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('pointer drags follow the primary pointer only', () => {
|
||||
const env = fixture();
|
||||
env.handle.dispatch('pointerdown', {
|
||||
button: 2,
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.5,
|
||||
'non-primary buttons never start a drag',
|
||||
);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.5,
|
||||
'moves without a drag are ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.25);
|
||||
assert.deepEqual(env.handle.captured, [4]);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 9,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.25,
|
||||
'a different pointer id is ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerup', { pointerId: 9 });
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.75,
|
||||
'the drag survives a foreign pointerup',
|
||||
);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: Number.NaN,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.75,
|
||||
'unusable coordinates are ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerup', { pointerId: 4 });
|
||||
assert.deepEqual(env.handle.released, [4]);
|
||||
env.handle.dispatch('pointermove', {
|
||||
clientX: 100,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.75, 'the drag ended');
|
||||
assert.deepEqual(env.changes, [0.25, 0.75]);
|
||||
});
|
||||
|
||||
test('visibility toggles the line without touching the value', () => {
|
||||
const env = fixture();
|
||||
env.split.setVisible(false);
|
||||
assert.equal(env.line.hidden, true);
|
||||
env.split.setVisible(true);
|
||||
assert.equal(env.line.hidden, false);
|
||||
assert.equal(env.split.getValue(), 0.5);
|
||||
});
|
||||
|
||||
test('destroy removes the line, clears the CSS property, restores the scene split once, and is idempotent', () => {
|
||||
const env = fixture({ initialValue: 0.8, splitPosition: 0.37 });
|
||||
env.handle.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
pointerId: 2,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.1);
|
||||
env.split.destroy();
|
||||
assert.equal(env.line.removed, true);
|
||||
assert.deepEqual(
|
||||
env.handle.released,
|
||||
[2],
|
||||
'an in-flight drag releases its capture',
|
||||
);
|
||||
assert.equal(env.cssCalls.at(-1)[0], 'remove');
|
||||
assert.equal(env.scene.splitPosition, 0.37);
|
||||
assert.equal(env.handle._listeners.size, 0);
|
||||
env.scene.splitPosition = 0.9;
|
||||
env.split.destroy();
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.9,
|
||||
'a second destroy does not restore again',
|
||||
);
|
||||
assert.equal(env.cssCalls.filter(([kind]) => kind === 'remove').length, 1);
|
||||
assert.equal(
|
||||
env.split.setValue(0.2),
|
||||
false,
|
||||
'a destroyed split ignores writes',
|
||||
);
|
||||
assert.equal(env.scene.splitPosition, 0.9);
|
||||
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
assert.deepEqual(env.changes, [0.1], 'listeners are gone');
|
||||
});
|
||||
+28
-1
@@ -31,11 +31,12 @@ const PANEL_GROUPS = [
|
||||
},
|
||||
{
|
||||
label: 'Cameras',
|
||||
ids: ['cctv', 'alpr-cameras'],
|
||||
ids: ['cctv', 'recent-imagery'],
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
ids: [
|
||||
'alpr-cameras',
|
||||
'military-installations',
|
||||
'local-datacenters',
|
||||
'telegeography-submarine-cables',
|
||||
@@ -126,6 +127,8 @@ export class LayerPanel {
|
||||
this._removers = [];
|
||||
this._destroyed = false;
|
||||
this._cancelRowControlsRefresh = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._recentImageryPanel = null;
|
||||
}
|
||||
mount(container) {
|
||||
if (this._destroyed) return;
|
||||
@@ -138,8 +141,29 @@ export class LayerPanel {
|
||||
container?.ownerDocument?.getElementById?.('weather-panel-body'),
|
||||
setLayerParams: this.setLayerParams,
|
||||
});
|
||||
this._mountRecentImagery();
|
||||
this._renderToggles();
|
||||
}
|
||||
/**
|
||||
* Host the Recent Imagery readout in its rail body, like the weather
|
||||
* readout. The application supplies the factory once the layer, viewer and
|
||||
* box tool exist; a remount rebuilds the readout in place.
|
||||
* @param {((container: HTMLElement) => { destroy: () => void } | null) | null} factory
|
||||
*/
|
||||
attachRecentImagery(factory) {
|
||||
if (this._destroyed) return;
|
||||
this._recentImageryFactory = typeof factory === 'function' ? factory : null;
|
||||
this._mountRecentImagery();
|
||||
}
|
||||
_mountRecentImagery() {
|
||||
this._recentImageryPanel?.destroy();
|
||||
this._recentImageryPanel = null;
|
||||
const container = this._toggleContainer?.ownerDocument?.getElementById?.(
|
||||
'recent-imagery-panel-body',
|
||||
);
|
||||
if (container && this._recentImageryFactory)
|
||||
this._recentImageryPanel = this._recentImageryFactory(container) || null;
|
||||
}
|
||||
_bind(element, type, listener) {
|
||||
element.addEventListener(type, listener);
|
||||
this._removers.push(() => element.removeEventListener(type, listener));
|
||||
@@ -156,6 +180,9 @@ export class LayerPanel {
|
||||
this._releaseBindings();
|
||||
this._weatherPanel?.destroy();
|
||||
this._weatherPanel = null;
|
||||
this._recentImageryPanel?.destroy();
|
||||
this._recentImageryPanel = null;
|
||||
this._recentImageryFactory = null;
|
||||
this._toggleContainer = null;
|
||||
}
|
||||
_renderToggles() {
|
||||
|
||||
@@ -174,3 +174,56 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('the Recent Imagery readout mounts in its rail body like the weather readout and is rebuilt or released with the panel', async () => {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const { railFixture } = await import('./railTestFixture.mjs');
|
||||
const f = railFixture();
|
||||
const body = f.document.createElement('div');
|
||||
f.document.getElementById = (id) =>
|
||||
id === 'recent-imagery-panel-body' ? body : null;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = f.document;
|
||||
const mounted = [];
|
||||
const factory = (container) => {
|
||||
const readout = {
|
||||
container,
|
||||
destroyed: false,
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
},
|
||||
};
|
||||
mounted.push(readout);
|
||||
return readout;
|
||||
};
|
||||
const panel = new LayerPanel({
|
||||
getLayers: () => [],
|
||||
isEnabled: () => false,
|
||||
setEnabled() {},
|
||||
setLayerParams() {},
|
||||
hasRowControls: () => false,
|
||||
subscribeRowControls() {},
|
||||
getRowControls: () => null,
|
||||
});
|
||||
try {
|
||||
panel.attachRecentImagery(factory);
|
||||
assert.equal(mounted.length, 0, 'no body before the panel mounts');
|
||||
panel.mount(f.container);
|
||||
assert.equal(mounted.length, 1);
|
||||
assert.equal(mounted[0].container, body);
|
||||
panel.mount(f.container);
|
||||
assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it');
|
||||
assert.equal(mounted.length, 2);
|
||||
panel.attachRecentImagery(null);
|
||||
assert.equal(mounted[1].destroyed, true);
|
||||
panel.attachRecentImagery(factory);
|
||||
assert.equal(mounted.length, 3);
|
||||
panel.destroy();
|
||||
assert.equal(mounted[2].destroyed, true);
|
||||
panel.attachRecentImagery(factory);
|
||||
assert.equal(mounted.length, 3, 'a destroyed panel mounts nothing');
|
||||
} finally {
|
||||
panel.destroy();
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||
{ id: 'data-panel' },
|
||||
{ id: 'cctv-panel' },
|
||||
{ id: 'weather-panel' },
|
||||
{ id: 'recent-imagery-panel' },
|
||||
{ id: 'radio-panel' },
|
||||
{ id: 'scene-panel' },
|
||||
{ id: 'global-context-panel' },
|
||||
@@ -23,6 +24,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||
'data-panel',
|
||||
'cctv-panel',
|
||||
'weather-panel',
|
||||
'recent-imagery-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
@@ -284,6 +286,7 @@ export class PanelChrome {
|
||||
'pp-toggles',
|
||||
'cctv-panel',
|
||||
'weather-panel',
|
||||
'recent-imagery-panel',
|
||||
'global-context-panel',
|
||||
].includes(panelEl?.id);
|
||||
const collapsed = panelEl.classList.contains('collapsed');
|
||||
|
||||
@@ -95,6 +95,7 @@ export class PanelLayoutController {
|
||||
this._ppToggles = document.getElementById('pp-toggles');
|
||||
this._cctvPanel = document.getElementById('cctv-panel');
|
||||
this._weatherPanel = document.getElementById('weather-panel');
|
||||
this._recentImageryPanel = document.getElementById('recent-imagery-panel');
|
||||
this._sliderPanel = document.getElementById('param-slider-panel');
|
||||
this._detectionBtn = document.getElementById('detection-toggle');
|
||||
}
|
||||
@@ -195,7 +196,11 @@ export class PanelLayoutController {
|
||||
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
|
||||
stack.prepend(this._ppToggles);
|
||||
const globalContextPanel = document.getElementById('global-context-panel');
|
||||
for (const panel of [this._cctvPanel, this._weatherPanel]) {
|
||||
for (const panel of [
|
||||
this._cctvPanel,
|
||||
this._weatherPanel,
|
||||
this._recentImageryPanel,
|
||||
]) {
|
||||
if (!panel) continue;
|
||||
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
|
||||
panel.style.removeProperty(property);
|
||||
@@ -222,6 +227,7 @@ export class PanelLayoutController {
|
||||
this._ppToggles,
|
||||
this._cctvPanel,
|
||||
this._weatherPanel,
|
||||
this._recentImageryPanel,
|
||||
globalContextPanel,
|
||||
]) {
|
||||
if (panel) this._rightStackResizeObserver.observe(panel);
|
||||
|
||||
@@ -577,3 +577,47 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
|
||||
const f = fixture('right');
|
||||
const imagery = element('recent-imagery-panel', { height: 0 });
|
||||
imagery.hidden = true;
|
||||
imagery.scrollHeight = 0;
|
||||
imagery.rect.height = 0;
|
||||
f.stack.children.push(imagery);
|
||||
imagery.parentElement = f.stack;
|
||||
// A tall panel expands into focus mode: the hidden sibling is not among the
|
||||
// later panels that focus mode collapses, and it counts for nothing.
|
||||
f.expand(f.first, 900);
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.layoutMode, 'focus');
|
||||
assert.equal(imagery.classList.contains('collapsed'), false);
|
||||
assert.equal(imagery.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.equal(
|
||||
imagery.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
'',
|
||||
);
|
||||
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
|
||||
const alone = parseFloat(
|
||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
);
|
||||
// Shown (and expanded) it joins the allocation like any other panel.
|
||||
imagery.hidden = false;
|
||||
imagery.scrollHeight = 300;
|
||||
imagery.rect.height = 300;
|
||||
const retriesBefore = f.retries();
|
||||
f.run();
|
||||
assert.ok(
|
||||
f.collapsed.includes('recent-imagery-panel'),
|
||||
'focus mode collapses the later panel and asks for another pass',
|
||||
);
|
||||
assert.equal(f.retries(), retriesBefore + 1);
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.ok(
|
||||
parseFloat(
|
||||
f.first.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
) <= alone,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -163,6 +163,7 @@
|
||||
#right-context-rail > #pp-toggles,
|
||||
#right-context-rail > #cctv-panel,
|
||||
#right-context-rail > #weather-panel,
|
||||
#right-context-rail > #recent-imagery-panel,
|
||||
#right-context-rail > #global-context-panel {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
@@ -175,19 +176,23 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
#right-context-rail #cctv-panel,
|
||||
#right-context-rail #weather-panel {
|
||||
#right-context-rail #weather-panel,
|
||||
#right-context-rail #recent-imagery-panel {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#right-context-rail > #weather-panel {
|
||||
#right-context-rail > #weather-panel,
|
||||
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
|
||||
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
#right-context-rail #cctv-panel.collapsed,
|
||||
#right-context-rail #weather-panel.collapsed {
|
||||
#right-context-rail #weather-panel.collapsed,
|
||||
#right-context-rail #recent-imagery-panel.collapsed {
|
||||
width: var(--right-display-collapsed-width);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -201,6 +206,7 @@
|
||||
#right-context-rail > #pp-toggles.collapsed .pp-header-row,
|
||||
#right-context-rail #cctv-panel.collapsed .cctv-panel-inner,
|
||||
#right-context-rail #weather-panel.collapsed .weather-panel-inner,
|
||||
#right-context-rail #recent-imagery-panel.collapsed .recent-imagery-panel-inner,
|
||||
#right-context-rail
|
||||
#global-context-panel.collapsed
|
||||
.global-context-panel-inner {
|
||||
@@ -257,6 +263,7 @@
|
||||
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #weather-panel:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed),
|
||||
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
|
||||
flex: 0 1 var(--right-panel-allocated-height);
|
||||
height: var(--right-panel-allocated-height);
|
||||
@@ -275,6 +282,9 @@
|
||||
#right-context-rail.layout-focus
|
||||
> #cctv-panel:not(.collapsed)
|
||||
.cctv-panel-inner,
|
||||
#right-context-rail.layout-focus
|
||||
> #recent-imagery-panel:not(.collapsed)
|
||||
.recent-imagery-panel-inner,
|
||||
#right-context-rail.layout-focus
|
||||
> #global-context-panel:not(.collapsed)
|
||||
.global-context-panel-inner {
|
||||
@@ -321,6 +331,9 @@
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .weather-panel-inner,
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .recent-imagery-panel-inner,
|
||||
#right-context-rail[data-rail-measuring]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
> .global-context-panel-inner {
|
||||
@@ -1364,7 +1377,8 @@ body.recording-mode #right-context-rail > [data-panel-id] {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
#right-context-rail #weather-panel.collapsed {
|
||||
#right-context-rail #weather-panel.collapsed,
|
||||
#right-context-rail #recent-imagery-panel.collapsed {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/* Recent Imagery: a right-rail readout laid out by the rail like WEATHER; only
|
||||
the body scrolls. Every block has a fixed height, so no click moves a
|
||||
control. Card typography comes from the rail-card rules in weather.css. */
|
||||
.recent-imagery-panel-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--panel-radius);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
|
||||
0 1px 0 rgba(255, 255, 255, 0.06) inset;
|
||||
}
|
||||
#recent-imagery-panel .panel-glow {
|
||||
position: absolute;
|
||||
inset: -18px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0, 212, 255, 0.16) 0%,
|
||||
transparent 65%
|
||||
);
|
||||
filter: blur(16px);
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
#recent-imagery-panel .panel-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#recent-imagery-panel-count {
|
||||
color: var(--text-secondary);
|
||||
font: 9px var(--font-mono);
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#recent-imagery-panel-body {
|
||||
min-height: 0;
|
||||
max-height: calc(var(--right-stack-max-height, 50svh) - 64px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 212, 255, 0.46) transparent;
|
||||
}
|
||||
#recent-imagery-panel.collapsed #recent-imagery-panel-body,
|
||||
#recent-imagery-panel.collapsed #recent-imagery-panel-count,
|
||||
#recent-imagery-panel[hidden],
|
||||
#recent-imagery-panel [hidden],
|
||||
#recent-imagery-split-line[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .recent-imagery-panel-inner {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ---- the fixed stack ---------------------------------------------------- */
|
||||
.recent-imagery-readout {
|
||||
--weather-card-border: rgba(98, 181, 255, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--text-dim);
|
||||
font: 9px/1.45 var(--font-mono);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.recent-imagery-readout .ri-block {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Reserved controls keep their box; they are only invisible. */
|
||||
.recent-imagery-readout .is-reserved {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.recent-imagery-readout .data-toggle-chip {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .data-toggle-chip:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 1. SELECT BOX · USE VIEW · CLEAR, three equal cells. */
|
||||
.recent-imagery-readout .ri-actions-row {
|
||||
height: 22px;
|
||||
}
|
||||
.recent-imagery-readout .ri-actions-row .rail-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
.recent-imagery-readout .ri-actions-row .data-toggle-chip {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* 2 and 3b. One-line notice and hint: always there, empty or not. */
|
||||
.recent-imagery-readout .ri-line {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The notice text yields room to ZOOM IN only while it shows; the chip sits
|
||||
in a fixed slot at the line's right end either way. The ellipsis is a
|
||||
safety net: every notice is written to fit the panel. */
|
||||
.recent-imagery-readout .ri-notice {
|
||||
position: relative;
|
||||
}
|
||||
.recent-imagery-readout .ri-notice-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.recent-imagery-readout .ri-notice.has-action .ri-notice-text {
|
||||
padding-right: 52px;
|
||||
}
|
||||
.recent-imagery-readout .ri-zoom-in {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 46px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
font-size: 8px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-notice.warn {
|
||||
color: rgba(255, 196, 120, 0.95);
|
||||
}
|
||||
.recent-imagery-readout .ri-notice.info {
|
||||
color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-hint {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 3. The strip: fixed height, newest day first. */
|
||||
.recent-imagery-readout .ri-strip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 120px;
|
||||
padding: 2px 2px 6px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: thin;
|
||||
outline: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.recent-imagery-readout .ri-strip:focus-visible {
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-strip.searching {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.recent-imagery-readout .ri-strip-empty {
|
||||
flex: 1 0 100%;
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.recent-imagery-readout .ri-card {
|
||||
position: relative;
|
||||
flex: 0 0 94px;
|
||||
height: 112px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: 42px 13px 11px 11px 18px;
|
||||
row-gap: 2px;
|
||||
padding: 3px 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
scroll-snap-align: start;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
.recent-imagery-readout .ri-card:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
.recent-imagery-readout .ri-card.focused {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-card.preview {
|
||||
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.35);
|
||||
}
|
||||
.recent-imagery-readout .ri-card.pinned {
|
||||
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.75);
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: rgba(120, 130, 140, 0.18);
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb[data-status='empty'] {
|
||||
background: rgba(90, 96, 104, 0.22);
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.recent-imagery-readout .ri-thumb-text {
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-flag,
|
||||
.recent-imagery-readout .ri-card-start {
|
||||
position: absolute;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(4, 17, 27, 0.9);
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-flag {
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-start {
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
color: rgba(255, 196, 120, 0.95);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-date,
|
||||
.recent-imagery-readout .ri-card-sensor,
|
||||
.recent-imagery-readout .ri-card-cloud {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-date {
|
||||
font-size: 10px;
|
||||
line-height: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-sensor,
|
||||
.recent-imagery-readout .ri-card-cloud {
|
||||
font-size: 8px;
|
||||
line-height: 11px;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-card-chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
.recent-imagery-readout .ri-card-chips .data-toggle-chip {
|
||||
height: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 4. The selection bar: mode, then two rows that are always there. */
|
||||
.recent-imagery-readout .ri-selection {
|
||||
display: grid;
|
||||
grid-template-rows: 20px 20px 20px;
|
||||
row-gap: 4px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--weather-card-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.recent-imagery-readout .ri-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
.recent-imagery-readout .ri-selection .data-toggle-chip {
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 22px;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot-tag {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot.lit .ri-slot-tag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-slot-value {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-slot.lit .ri-slot-value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.recent-imagery-readout .ri-slot[data-state='preview'] .ri-slot-value {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
.recent-imagery-readout .ri-unpin {
|
||||
width: 22px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
|
||||
while no swipe is live. */
|
||||
.recent-imagery-readout .ri-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 22px;
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.recent-imagery-readout .ri-swap {
|
||||
width: 40px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.recent-imagery-readout .ri-exports .rail-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 6. DETAILS: a collapsed rail card at the bottom, for every note. */
|
||||
.recent-imagery-readout .ri-details .rail-card {
|
||||
border-color: var(--weather-card-border);
|
||||
}
|
||||
.recent-imagery-readout .ri-details .rail-card-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.recent-imagery-readout .ri-details .rail-card-line.muted {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.recent-imagery-readout .ri-details .rail-card-actions .data-toggle-chip {
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
/* ---- box tool cursor ---------------------------------------------------- */
|
||||
body.gev-imagery-box #cesium-container canvas,
|
||||
body.gev-imagery-box .cesium-widget canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ---- split line --------------------------------------------------------- */
|
||||
/* Above the right rail (z-index 110) so the handle can always be grabbed. */
|
||||
#recent-imagery-split-line {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--recent-imagery-split, 50%);
|
||||
z-index: 130;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
background: rgba(218, 241, 247, 0.76);
|
||||
box-shadow: 0 0 12px rgba(94, 216, 239, 0.72);
|
||||
}
|
||||
#recent-imagery-split-line span {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(4, 17, 27, 0.82);
|
||||
color: rgba(239, 247, 249, 0.92);
|
||||
font: 700 9px/1.4 var(--font-mono);
|
||||
letter-spacing: 1.2px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
#recent-imagery-split-line span.before {
|
||||
right: 8px;
|
||||
}
|
||||
#recent-imagery-split-line span.after {
|
||||
left: 8px;
|
||||
}
|
||||
#recent-imagery-split-line .recent-imagery-split-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(180, 215, 222, 0.72);
|
||||
border-radius: 50%;
|
||||
outline: 0;
|
||||
color: rgba(239, 247, 249, 0.96);
|
||||
background: rgba(4, 17, 27, 0.96);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(62, 112, 125, 0.3),
|
||||
0 0 13px rgba(94, 216, 239, 0.3);
|
||||
font: 700 14px/1 var(--font-mono);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
#recent-imagery-split-line .recent-imagery-split-handle:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#recent-imagery-panel-body {
|
||||
max-height: 48vh;
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,8 @@
|
||||
#right-context-rail #pp-toggles.collapsed .pp-header-row,
|
||||
#right-context-rail #cctv-panel,
|
||||
#right-context-rail #cctv-panel.collapsed,
|
||||
#right-context-rail #recent-imagery-panel,
|
||||
#right-context-rail #recent-imagery-panel.collapsed,
|
||||
#right-context-rail #radio-panel,
|
||||
#right-context-rail #radio-panel.collapsed,
|
||||
#right-context-rail #global-context-panel,
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
<div id="weather-panel-body" data-rail-scroller></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
|
||||
<div class="panel-glow"></div>
|
||||
<div class="recent-imagery-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">RECENT IMAGERY</span>
|
||||
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box"></span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel">▼</button>
|
||||
</div>
|
||||
<div id="recent-imagery-panel-body" data-rail-scroller></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="global-context-panel-inner">
|
||||
|
||||
Reference in new issue
Block a user