Files
gods-eye-view/src/ui/recentImagery.js
T
Sameh Khamis e5fa31d01e feat(controls): render every segmented group with UiSegmented
Street Level's fit and panorama filters and Recent Imagery's mode render
through the kit's UiSegmented, and their owners answer the choice
through the Street Level store (setPano, setRenderMode) and the
readout's onSetMode. The imperative segmentedControl.js adapter and its
test go. UiSegmented moves focus at once on a key, a radio group from
the option holding focus and an arrows group from the selection, and
asks for a change only when the key lands somewhere new.
2026-11-01 10:38:25 -08:00

962 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Recent Imagery readout for the right rail (`#recent-imagery-panel`). The
* body is a fixed stack in which no block changes height and no control
* moves, whatever the state: box actions, a notice line, the strip of day
* cards and its hint line, the selection bar (mode, the two slot rows),
* opacity, SWAP and export, then a collapsed DETAILS card for every note and
* hedge. The adapter computes the view from the layer's snapshot and renders
* the RecentImageryReadout component; box actions, export and DETAILS are
* rail-card blocks and cards. Source toggles live on the data-panel row.
*/
import { h, render as renderVue } from 'vue';
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
import { createImagerySplit } from './imagerySplit.js';
import RecentImageryReadout from './recentImagery/RecentImageryReadout.vue';
import {
PRODUCTS,
hhmm,
wvsSnapshotUrl,
} from '../layers/recentImagery/model.js';
import { displayDay, displayMetres } from '../layers/recentImagery/display.js';
import { formatQuantity } from './unitLabels.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
const PANEL_ID = 'recent-imagery-panel';
const OVERVIEW_MIN_BOX_KM = 25;
const ESRI_NOTE = msg('Imagery on Esri · Google 3D returns when cleared');
// The layer keeps its notices and box refusals in English (AI tools read the
// same snapshot); translate the ones we write, show anything else as it came.
const LAYER_NOTICES = new Set([
ESRI_NOTE,
msg('Box and images cleared'),
msg('Esri map unavailable · no swipe'),
msg('Swipe needs a globe map'),
msg('Imagery stays on Esri · CLEAR to use Google 3D'),
// Box refusals the layer writes itself (layers/recentImagery/index.js).
msg('Point the camera at the ground to use the view'),
msg('That point cannot anchor a box'),
]);
// Refusals with a value in them (layers/recentImagery/model.js and
// imageryBoxTool.js); fixed refusals there are msg() text for translateKnown.
const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/;
const POLAR = /^Imagery stops at ±([\d.]+)° latitude$/;
const POINTER_BUSY = /^(.+) is using the pointer — close it first$/;
/**
* The notice line in the UI language: notices and box refusals arrive in
* English. Exported for tests.
* @param {string} text
* @returns {string}
*/
export function localizeNotice(text) {
if (!text) return '';
if (LAYER_NOTICES.has(text)) return t(text);
const polar = POLAR.exec(text);
if (polar)
return t('Imagery stops at ±{limit}° latitude', {
limit: formatNumber(Number(polar[1]), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
});
const busy = POINTER_BUSY.exec(text);
if (busy)
return t('{owner} is using the pointer — close it first', {
owner: translateKnown(pointerOwnerName(busy[1])),
});
const tooLarge = TOO_LARGE.exec(text);
if (!tooLarge) return translateKnown(text);
const values = {
size: formatQuantity(Number(tooLarge[2].replace(/,/g, '')), 'km', {
useGrouping: true,
}),
limit: formatQuantity(Number(tooLarge[3].replace(/,/g, '')), 'km', {
useGrouping: true,
}),
};
return tooLarge[1] === 'Box'
? t('Box is {size} wide · limit {limit}', values)
: t('View is {size} wide · limit {limit}', values);
}
const MODE_LABELS = [
['image', msg('IMAGE')],
['basemap', msg('VS BASEMAP')],
['ab', msg('A / B')],
];
const START_HERE_TITLES = {
clear: msg('Newest low-cloud day for this box · scene cloud, not box cloud'),
cloudy: msg('Newest day covering this box · cloudier than 20%'),
partial: msg('Newest day with imagery for this box · partial coverage'),
overview: msg('Newest daily overview for this box'),
};
const START_HERE_REASONS = {
clear: msg('newest clear day'),
cloudy: msg('newest day (cloudy)'),
partial: msg('newest day (partial coverage)'),
overview: msg('newest overview'),
};
const PLACEHOLDERS = {
empty: msg('No imagery'),
error: msg('Unavailable'),
present: msg('Loading'),
};
const KEY_STEPS = {
ArrowLeft: (index) => index - 1,
ArrowRight: (index) => index + 1,
Home: () => 0,
End: (index, total) => total - 1,
};
// Remounting must not reopen a panel the user already collapsed.
const appearedDocuments = new WeakSet();
const sensorLine = (product) =>
PRODUCTS[product]
? `${t(PRODUCTS[product].name)} · ${displayMetres(PRODUCTS[product].resolutionM)}`
: product || '';
const utcTime = (iso) => (hhmm(iso) ? `${hhmm(iso)}Z` : '');
const longerSideKm = (size) =>
Math.max(Number(size?.width) || 0, Number(size?.height) || 0);
const kmText = (km) => {
if (!Number.isFinite(km)) return '?';
const digits = km >= 100 ? 0 : 1;
return formatNumber(km, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
function coverageText(coverage) {
if (coverage === 'full') return t('full', { context: 'coverage' });
if (coverage === 'partial') return t('partial', { context: 'coverage' });
return t('unknown');
}
function cloudText(candidate) {
if (candidate.thumbnail?.status === 'empty') return t('no imagery');
if (!candidate.cloud) return t('cloud unknown');
const min = Math.round(candidate.cloud.min);
const max = Math.round(candidate.cloud.max);
return min === max
? t('{percent}% cloud', { percent: min })
: t('{min}–{max}% cloud', { min, max });
}
function countText(snapshot) {
if (!snapshot.box) return t('NO BOX');
if (snapshot.searching) return t('SEARCHING');
return tn(snapshot.candidates.length, '{count} DAY', '{count} DAYS');
}
/** The one-line hint under the strip: the next thing to do, per state. */
function hintText(snapshot) {
if (snapshot.zoomToFit) return t('Zoom in or draw a smaller box');
if (!snapshot.box) return t('Select a box or use the view');
if (snapshot.searching) return t('Searching the last 30 days');
if (!snapshot.candidates.length) return t('No days to show for this box');
const { mode, pins, shown } = snapshot;
if (shown.swipe !== 'none') return t('Drag the divider · SWAP trades sides');
if (mode === 'ab') {
if (!pins.a.key && !pins.b.key)
return t('← → preview · A or B pins the focused day');
return t('← → preview the other side · A or B pins it');
}
if (pins.a.key) return t('S on another day replaces it · × unpins');
return t('← → preview · S shows the focused day');
}
/**
* Bring a newly opened card into the scrolled body: one write to the body's
* own scroll position, never on a refresh.
*/
function revealCard(scroller, card) {
const height = scroller?.clientHeight;
if (!(height > 0) || typeof card?.getBoundingClientRect !== 'function')
return;
const view = scroller.getBoundingClientRect();
const box = card.getBoundingClientRect();
const top = box.top - view.top - (scroller.clientTop || 0);
const delta =
top < 0 || box.height > height
? top
: Math.max(0, top + box.height - height);
if (delta) scroller.scrollTop = (scroller.scrollTop || 0) + delta;
}
/**
* Mount the readout into the panel body.
* @param {{ container: HTMLElement, layer: object, viewer?: object, tool?: object, createSplit?: Function, fetchImpl?: typeof fetch, createObjectUrl?: Function, revokeObjectUrl?: Function, requestRender?: Function }} deps
* @returns {{ root: HTMLElement, exportImage: (slot?: 'a' | 'b') => Promise<boolean>, destroy: () => void } | null}
*/
export function createRecentImageryPanel({
container,
layer,
viewer = null,
tool = null,
createSplit = createImagerySplit,
fetchImpl = globalThis.fetch?.bind(globalThis),
createObjectUrl = (blob) => URL.createObjectURL(blob),
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
requestRender = () => viewer?.scene?.requestRender?.(),
} = {}) {
const document = container?.ownerDocument;
if (!document?.createElement || !layer) return null;
const panel = document.getElementById?.(PANEL_ID) || null;
const count = document.getElementById?.(`${PANEL_ID}-count`) || null;
const listeners = [];
const listen = (target, type, handler, options) => {
target.addEventListener(type, handler, options);
listeners.push([target, type, handler, options]);
};
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
// ---- state ------------------------------------------------------------
let snapshot = layer.getSnapshot();
let split = null;
let splitSignature = '';
let exportError = null;
const exporting = new Set();
let destroyed = false;
let detailsOpen = false;
let stripKeys = '';
// Days whose thumbnail has been shown: their image stays, hidden when gone.
const imaged = new Set();
// The readout patches synchronously, so it can be measured at once. Once
// destroyed nothing paints: a late export would mount it again.
const paint = () =>
!destroyed &&
renderVue(
h(RecentImageryReadout, {
view: buildView(),
modes: MODE_LABELS,
onOpenDetails,
onSetMode: (mode) => layer.setMode(mode),
}),
container,
);
paint();
const root = container.lastElementChild;
const strip = root.querySelector('#ri-strip');
const zoomIn = root.querySelector('#ri-zoom-in');
const swap = root.querySelector('#ri-swap');
const opacity = root.querySelector('#ri-opacity');
/**
* The body is the scroll container and no render may move it: a
* re-render, a thumbnail arriving or a notification never sends the
* operator back to the top.
*/
function preserveScroll(fn) {
const top = Number(container.scrollTop) || 0;
try {
return fn();
} finally {
if ((Number(container.scrollTop) || 0) !== top) container.scrollTop = top;
}
}
// ---- box actions ------------------------------------------------------
function actionsView() {
return [
{
id: 'box',
type: 'actions',
actions: [
{
id: 'select-box',
label: t('SELECT BOX'),
title: t('Drag a box on the map (Esc cancels)'),
pressed: Boolean(snapshot.toolActive),
onClick: () => {
exportError = null;
if (tool?.isActive()) tool.cancel('toggle');
else tool?.start();
},
},
{
id: 'use-view',
label: t('USE VIEW'),
title: t('Use the current view as the box'),
onClick: () => {
exportError = null;
layer.useCurrentView(viewer);
},
},
{
id: 'clear',
label: t('CLEAR', { context: 'action' }),
title: t('Forget the box and its images'),
disabled: !snapshot.box && !snapshot.boxError,
onClick: () => {
exportError = null;
layer.clear();
},
},
],
},
];
}
// ---- notice -----------------------------------------------------------
function noticeView() {
const warning = snapshot.boxError || snapshot.error || exportError;
const text = localizeNotice(
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : ''),
);
return {
text,
warn: Boolean(warning),
info: !warning && Boolean(text),
// ZOOM IN only for the oversized box or view the notice is showing.
fit: Boolean(snapshot.zoomToFit),
};
}
// ---- strip ------------------------------------------------------------
function cardView(candidate, index) {
const day = displayDay(candidate.day);
const ab = snapshot.mode === 'ab';
const focused = index === snapshot.focusIndex;
const status = candidate.thumbnail?.status || 'unknown';
const objectUrl = candidate.thumbnail?.objectUrl || null;
const shown = status === 'present' && Boolean(objectUrl);
if (shown) imaged.add(candidate.key);
const flag = candidate.preview
? t('PREVIEW')
: candidate.pending
? t('LOADING')
: '';
const recommended = snapshot.recommended?.key === candidate.key;
const empty = status === 'empty';
const chips = ['a', 'b'].map((slotId) => {
const live = slotId === 'a' || ab;
return {
slot: slotId,
label: slotId === 'a' ? (ab ? 'A' : t('SHOW')) : 'B',
disabled: empty || !live,
active: candidate.pinned === slotId && live,
reserved: !live,
ariaLabel: ab
? t('Pin {day} as {slot}', { day, slot: slotId.toUpperCase() })
: t('Show {day}', { day }),
};
});
const state = candidate.pinned
? ab
? t('pinned {slot}', { slot: candidate.pinned.toUpperCase() })
: candidate.pinned === 'a'
? t('shown')
: ''
: candidate.preview
? t('preview')
: '';
return {
key: candidate.key,
id: `ri-card-${index}`,
focused,
pinned: Boolean(candidate.pinned),
preview: Boolean(candidate.preview),
status,
image: imaged.has(candidate.key)
? { src: shown ? objectUrl : '', hidden: !shown }
: null,
placeholder: PLACEHOLDERS[status]
? t(PLACEHOLDERS[status])
: t('Checking'),
flag,
start: recommended
? t(
START_HERE_TITLES[snapshot.recommended.reason] ||
msg('Newest day with imagery for this box'),
)
: '',
date: day,
sensor: sensorLine(candidate.product),
cloud: cloudText(candidate),
chips,
ariaLabel: [
day,
sensorLine(candidate.product),
cloudText(candidate),
...[recommended ? t('start here') : '', state].filter(Boolean),
].join(' · '),
};
}
function stripView() {
const candidates = snapshot.candidates;
// A day that leaves the strip loses its image with its card.
const keys = new Set(candidates.map((candidate) => candidate.key));
for (const key of imaged) if (!keys.has(key)) imaged.delete(key);
return {
cards: candidates.map(cardView),
empty: candidates.length
? ''
: !snapshot.box
? t('No box')
: snapshot.searching
? t('Searching')
: snapshot.hiddenCount
? t('Every day is empty here')
: !snapshot.sources.hls && !snapshot.sources.viirs
? t('Sources off')
: t('No imagery'),
activeDescendant: candidates[snapshot.focusIndex]
? `ri-card-${snapshot.focusIndex}`
: null,
searching: Boolean(snapshot.searching),
};
}
/** The strip's day cards by key. */
const cardNodes = () =>
new Map(
[...strip.querySelectorAll('.ri-card')].map((node) => [
node.dataset.key,
node,
]),
);
/**
* Keyboard only: slide the strip until the focused card is in view. Only
* `strip.scrollLeft` moves — `scrollIntoView` would also scroll the body.
*/
function scrollFocusedIntoView() {
const focus = snapshot.candidates[snapshot.focusIndex];
const card = focus ? cardNodes().get(focus.key) : null;
const left = Number(card?.offsetLeft) || 0;
const width = Number(card?.offsetWidth) || 0;
const viewLeft = Number(strip.scrollLeft) || 0;
const viewWidth = Number(strip.clientWidth) || 0;
if (!width || !viewWidth) return;
if (left < viewLeft) strip.scrollLeft = left;
else if (left + width > viewLeft + viewWidth)
strip.scrollLeft = left + width - viewWidth;
}
function reportVisibleRange() {
const width = Number(strip.clientWidth) || 0;
if (!width || !snapshot.candidates.length) return;
const left = Number(strip.scrollLeft) || 0;
let first = null;
let last = null;
const nodes = cardNodes();
snapshot.candidates.forEach((candidate, index) => {
const card = nodes.get(candidate.key);
const start = Number(card?.offsetLeft) || 0;
const end = start + (Number(card?.offsetWidth) || 0);
if (!card || end < left || start > left + width) return;
first ??= index;
last = index;
});
if (first !== null) layer.setVisibleRange(first, last);
}
// ---- selection bar ----------------------------------------------------
/** What slot `slotId` holds: its pin, else the preview filling it. */
function slotImage(slotId) {
const pin = snapshot.pins[slotId];
if (pin.key) return { ...pin, preview: false };
if (snapshot.preview.slot === slotId && snapshot.preview.key)
return { ...snapshot.preview, preview: true };
return null;
}
function slotsView() {
const { mode } = snapshot;
const ab = mode === 'ab';
return ['a', 'b'].map((slotId) => {
const basemapRow = slotId === 'b' && !ab;
const image = basemapRow ? null : slotImage(slotId);
const pinned = Boolean(image && !image.preview);
let text = t('Not set');
if (basemapRow) text = t('Basemap');
else if (image?.label)
text = [
image.sourceOff ? t('Source off') : '',
image.label,
image.preview
? t('preview')
: image.candidate && !image.drapable && !image.sourceOff
? t('loading')
: '',
]
.filter(Boolean)
.join(' · ');
return {
id: slotId,
tag: ab ? slotId.toUpperCase() : slotId === 'a' ? t('IMAGE') : t('VS'),
text,
lit: basemapRow
? mode === 'basemap'
: Boolean(image) && !image.sourceOff,
state: pinned ? 'pinned' : image ? 'preview' : 'empty',
pinned,
unpinLabel: ab
? t('Unpin {slot}', { slot: slotId.toUpperCase() })
: t('Unpin the image'),
};
});
}
// ---- opacity and export -----------------------------------------------
/** The image a slot's EXPORT downloads: its pin, else its preview. */
function exportTarget(slotId) {
if (slotId === 'b' && snapshot.mode !== 'ab') return null;
const image = slotImage(slotId);
return image?.candidate && !image.sourceOff ? image.candidate : null;
}
function exportsView() {
const ab = snapshot.mode === 'ab';
return [
{
id: 'export',
type: 'actions',
actions: ['a', 'b'].map((slotId) => ({
id: `export-${slotId}`,
label: ab
? t('EXPORT {slot}', { slot: slotId.toUpperCase() })
: t('EXPORT'),
title: ab
? t('Download {slot} as a PNG', { slot: slotId.toUpperCase() })
: t('Download the image as a PNG'),
disabled: !exportTarget(slotId) || exporting.has(slotId),
reserved: slotId === 'b' ? !ab : undefined,
onClick: () => void exportImage(slotId),
})),
},
];
}
// ---- details ----------------------------------------------------------
function detailLines() {
const focus = snapshot.focus;
const lines = [];
if (snapshot.box)
lines.push({
id: 'box',
text: t('Box {width} × {height} km', {
width: kmText(snapshot.boxSizeKm?.width),
height: kmText(snapshot.boxSizeKm?.height),
}),
});
if (focus) {
lines.push({ id: 'readout', text: snapshot.readout || '' });
const times = [];
if (focus.timeRange?.start) {
const start = utcTime(focus.timeRange.start);
const end = utcTime(focus.timeRange.end || focus.timeRange.start);
times.push(
t('Acquired {time}', {
time: start === end ? start : `${start}–${end}`,
}),
);
} else if (focus.thumbnail?.acquisitionTime)
times.push(
t('Acquired {time}', {
time: utcTime(focus.thumbnail.acquisitionTime),
}),
);
times.push(
t('Coverage {coverage}', { coverage: coverageText(focus.coverage) }),
);
lines.push({ id: 'acquired', text: times.join(' · ') });
(focus.granules || []).forEach((granule, index) => {
const cloud = Number.isFinite(granule.cloud)
? ` · ${t('{percent}% cloud', { percent: Math.round(granule.cloud) })}`
: '';
lines.push({
id: `granule-${index}`,
text: `${granule.id || granule.product} · ${utcTime(granule.timeStart)}${cloud}`,
muted: true,
});
});
}
const reason = START_HERE_REASONS[snapshot.recommended?.reason];
if (reason)
lines.push({
id: 'start',
text: t('START HERE · {reason}', { reason: t(reason) }),
});
for (const [index, note] of snapshot.notes.entries())
lines.push({ id: `note-${index}`, text: note, muted: true });
if (
snapshot.sources.viirs &&
snapshot.box &&
longerSideKm(snapshot.boxSizeKm) > 0 &&
longerSideKm(snapshot.boxSizeKm) < OVERVIEW_MIN_BOX_KM
)
lines.push({
id: 'overview-scale',
text: t('Daily overview shows little detail in a box this small'),
muted: true,
});
lines.push({
id: 'credit',
text: t(
'Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS',
),
muted: true,
});
return lines;
}
function detailsView() {
const hidden = snapshot.hiddenCount;
const showing = snapshot.showUnavailable;
return [
{
id: 'details',
title: t('Details'),
open: detailsOpen,
compact: snapshot.focus
? snapshot.readout
: t('Times, coverage, sources'),
blocks: [
{ id: 'lines', type: 'lines', lines: detailLines() },
{
id: 'empty-days',
type: 'actions',
actions: [
{
id: 'toggle-empty',
label: showing
? t('HIDE EMPTY DAYS')
: t('SHOW EMPTY DAYS · {count}', { count: hidden }),
title: t('Days the probe found empty in this box'),
disabled: !showing && !hidden,
onClick: () => layer.setShowUnavailable(!showing),
},
],
},
],
},
];
}
function onOpenDetails() {
detailsOpen = !detailsOpen;
render();
// After the render: preserveScroll() puts the old position back.
if (detailsOpen)
revealCard(container, root.querySelector('#ri-details > [data-card-id]'));
}
function buildView() {
return {
hidden: !snapshot.enabled,
mode: snapshot.mode,
actions: actionsView(),
notice: noticeView(),
strip: stripView(),
hint: hintText(snapshot),
slots: slotsView(),
alpha: String(Math.round(snapshot.alpha * 100)),
opacityEnabled: Boolean(snapshot.shown.a || snapshot.shown.b),
swap: {
live: Boolean(snapshot.comparison.active),
pressed: Boolean(snapshot.comparison.active && snapshot.swapped),
},
exports: exportsView(),
details: detailsView(),
};
}
// ---- divider ----------------------------------------------------------
function renderSplit() {
const live = snapshot.comparison.active;
const basemap = snapshot.shown.swipe === 'basemap';
const swapped = Boolean(snapshot.swapped);
const a = snapshot.candidates.find((c) => c.key === snapshot.shown.a);
const b = snapshot.candidates.find((c) => c.key === snapshot.shown.b);
const describe = (c) => (c ? `${sensorLine(c.product)} · ${c.day}` : '');
// The two sides as [label, title, spoken name]; SWAP trades them.
const sides = [
basemap ? [t('IMAGE'), describe(a), t('image')] : ['A', describe(a), 'A'],
basemap
? [t('BASEMAP'), t('The basemap'), t('basemap')]
: ['B', describe(b), 'B'],
];
const [before, after] = swapped ? [sides[1], sides[0]] : sides;
const signature = live
? `${snapshot.shown.swipe}|${swapped}|${describe(a)}|${describe(b)}`
: '';
if (signature !== splitSignature) {
split?.destroy();
split = null;
splitSignature = signature;
}
if (!live || split || destroyed) return;
split = createSplit({
scene: viewer?.scene,
initialValue: snapshot.split,
id: 'recent-imagery-split-line',
handleClass: 'recent-imagery-split-handle',
cssTarget: document.documentElement,
cssProperty: '--recent-imagery-split',
beforeLabel: before[0],
afterLabel: after[0],
beforeTitle: before[1],
afterTitle: after[1],
ariaLabel: basemap
? t('Recent imagery against the basemap divider')
: t('Recent imagery A and B divider'),
formatValueText: (leftPercent, rightPercent) =>
t('{before} {left} percent, {after} {right} percent', {
before: `${before[2][0].toUpperCase()}${before[2].slice(1)}`,
left: leftPercent,
after: after[2],
right: rightPercent,
}),
getViewportWidth: () =>
Number(viewer?.scene?.canvas?.clientWidth) ||
Number(document.documentElement?.clientWidth) ||
0,
onChange: (value) => layer.setSplit(value),
requestRender,
});
split?.setValue(snapshot.split);
}
// ---- panel ------------------------------------------------------------
function renderPanel() {
const hidden = !snapshot.enabled;
if (panel) set(panel, 'hidden', hidden);
if (count) set(count, 'textContent', hidden ? '' : countText(snapshot));
if (hidden || !panel || appearedDocuments.has(document)) return;
appearedDocuments.add(document);
// First appearance opens the panel unless a stored or shared collapse
// choice exists (marked by the panel chrome when it restored the state).
const preference = panel.dataset?.collapsedPreference;
if (
panel.classList.contains('collapsed') &&
(preference === undefined || preference === 'default')
)
panel.querySelector?.(`[data-collapse-target="${PANEL_ID}"]`)?.click();
}
function render() {
if (destroyed) return;
// The strip a full render showed: thumbnails for it take the short path.
stripKeys = snapshot.candidates.map((candidate) => candidate.key).join('|');
preserveScroll(() => {
renderPanel();
paint();
renderSplit();
reportVisibleRange();
});
}
// ---- export -----------------------------------------------------------
async function exportImage(slotId = 'a') {
const candidate = exportTarget(slotId);
if (destroyed || !candidate || !snapshot.box || exporting.has(slotId))
return false;
exporting.add(slotId);
exportError = null;
preserveScroll(paint);
// The snapshot is plate carrée: keep the box's degree aspect, with the
// longer side at the size limit.
const size = longerSideKm(snapshot.boxSizeKm) > 25 ? 2048 : 1024;
const spanLon = Math.abs(snapshot.box.east - snapshot.box.west) || 1;
const spanLat = Math.abs(snapshot.box.north - snapshot.box.south) || 1;
const width = Math.max(
1,
Math.round(size * Math.min(1, spanLon / spanLat)),
);
const height = Math.max(
1,
Math.round(size * Math.min(1, spanLat / spanLon)),
);
try {
const response = await fetchImpl(
wvsSnapshotUrl({
product: candidate.product,
day: candidate.day,
box: snapshot.box,
width,
height,
}),
);
if (!response?.ok) throw new Error(`HTTP ${response?.status ?? 'error'}`);
const blob = await response.blob();
if (destroyed) return false;
const href = createObjectUrl(blob);
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = `recent-imagery-${candidate.product}-${candidate.day}.png`;
anchor.hidden = true;
document.body.appendChild(anchor);
try {
anchor.click();
} finally {
anchor.remove();
revokeObjectUrl(href);
}
return true;
} catch (error) {
if (!destroyed)
exportError = t('Export failed · {error}', {
error: error?.message || error,
});
return false;
} finally {
exporting.delete(slotId);
if (!destroyed) preserveScroll(paint);
}
}
// ---- input ------------------------------------------------------------
/** Escape clears the preview, then cancels the box tool, then blurs. */
function escape() {
if (layer.clearPreview()) return true;
if (tool?.isActive()) {
tool.cancel('escape');
return true;
}
strip.blur();
return false;
}
function pin(slotId, key) {
if (!key) return;
if (slotId === 'b' && snapshot.mode !== 'ab') return;
exportError = null;
layer.toggleAssignment(slotId, key);
}
listen(root, 'click', (event) => {
const target = event.target;
const chip = target?.closest?.('.ri-chip');
const card = target?.closest?.('.ri-card');
if (chip && card) {
if (!chip.disabled) pin(chip.dataset.slot, card.dataset.key);
strip.focus({ preventScroll: true });
return;
}
if (card) {
exportError = null;
layer.preview(card.dataset.key);
strip.focus({ preventScroll: true });
return;
}
if (target?.closest?.('.ri-swap') && !swap.disabled) {
layer.swapSides?.();
return;
}
const unpin = target?.closest?.('.ri-unpin');
if (unpin && !unpin.disabled)
layer.setAssignment(unpin.dataset.unpin, null);
// The layer owns the camera flight; the panel only asks.
if (target?.closest?.('.ri-zoom-in') && !zoomIn.disabled)
layer.zoomToFit?.();
});
listen(root, 'input', (event) => {
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
});
listen(strip, 'keydown', (event) => {
const total = snapshot.candidates.length;
const index = snapshot.focusIndex || 0;
const focus = snapshot.candidates[index];
if (event.key === 'Escape') {
event.preventDefault();
escape();
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (event.key === 'Enter') {
event.preventDefault();
// Enter is a click on the focused card; key repeat must not re-fire.
if (focus && !event.repeat) layer.preview(focus.key);
return;
}
const letter = String(event.key || '').toLowerCase();
const slotId =
letter === 'a' || (letter === 's' && snapshot.mode !== 'ab')
? 'a'
: letter === 'b'
? 'b'
: null;
if (slotId) {
event.preventDefault();
if (!event.repeat && focus && focus.thumbnail?.status !== 'empty')
pin(slotId, focus.key);
return;
}
const step = KEY_STEPS[event.key];
if (!step || !total) return;
event.preventDefault();
layer.focus(Math.max(0, Math.min(total - 1, step(index, total))));
preserveScroll(scrollFocusedIntoView);
});
listen(root, 'keydown', (event) => {
if (event.key === 'Escape' && event.target !== strip) {
if (
snapshot.preview.key ||
snapshot.preview.pending ||
tool?.isActive()
) {
event.preventDefault();
escape();
}
}
});
listen(strip, 'scroll', reportVisibleRange, { passive: true });
// ---- wiring -----------------------------------------------------------
layer.setToolHandler?.(() => {
if (tool?.isActive()) tool.cancel('layer');
});
const unsubscribe = layer.subscribe((next, reason) => {
if (destroyed) return;
snapshot = next || layer.getSnapshot();
if (reason === 'split') {
split?.setValue(snapshot.split);
} else if (reason === 'alpha') {
preserveScroll(paint);
} else if (
reason === 'thumbnail' &&
snapshot.candidates.map((candidate) => candidate.key).join('|') ===
stripKeys
) {
// A probe that emptied a day hides its card; only an unchanged strip
// takes this in-place path, without the divider or visible range.
preserveScroll(paint);
} else {
render();
}
});
render();
return {
root,
exportImage,
destroy() {
if (destroyed) return;
destroyed = true;
unsubscribe();
layer.setToolHandler?.(null);
split?.destroy();
split = null;
for (const [target, type, handler, options] of listeners.splice(0))
target.removeEventListener(type, handler, options);
for (const image of root.querySelectorAll('.ri-thumb-img'))
image.src = '';
imaged.clear();
renderVue(null, container);
if (count) count.textContent = '';
if (panel) panel.hidden = true;
},
};
}