mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
1193 lines
40 KiB
JavaScript
1193 lines
40 KiB
JavaScript
/**
|
||
* 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. Box actions, export and DETAILS are rail-card blocks and cards; the
|
||
* strip and selection bar are this adapter's own keyed DOM. Source toggles
|
||
* live on the data-panel row.
|
||
*/
|
||
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||
import { createRailCards } from './railCards.js';
|
||
import { createImagerySplit } from './imagerySplit.js';
|
||
import { createSegmentedControl } from './segmentedControl.js';
|
||
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;
|
||
}
|
||
|
||
/** Depth-first search over `children`, for nodes the blocks module owns. */
|
||
function findIn(root, predicate) {
|
||
for (const child of root?.children || []) {
|
||
if (predicate(child)) return child;
|
||
const inner = findIn(child, predicate);
|
||
if (inner) return inner;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 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 el = (tag, className, parent, text) => {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text != null) node.textContent = text;
|
||
parent?.appendChild(node);
|
||
return node;
|
||
};
|
||
const set = (node, key, value) => {
|
||
if (node[key] !== value) node[key] = value;
|
||
};
|
||
const attribute = (node, key, value) => {
|
||
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
|
||
};
|
||
const toggle = (node, name, on) => {
|
||
if (node.classList.contains(name) !== Boolean(on))
|
||
node.classList.toggle(name, Boolean(on));
|
||
};
|
||
|
||
// ---- build: every block exists from the start, in its final order ----
|
||
const root = el('section', 'recent-imagery-readout');
|
||
root.setAttribute('aria-label', t('Recent imagery'));
|
||
|
||
const actionsHost = el('div', 'ri-block ri-actions-row', root);
|
||
actionsHost.id = 'ri-actions';
|
||
const actions = createRailCardBlocks({
|
||
container: actionsHost,
|
||
cardId: 'recent-imagery',
|
||
onParams: () => {},
|
||
});
|
||
|
||
// The notice line: the text, and ZOOM IN in a fixed slot at its right end
|
||
// (reserved, never removed, whenever there is nothing to fit).
|
||
const notice = el('div', 'ri-block ri-line ri-notice', root);
|
||
notice.id = 'ri-notice';
|
||
const noticeText = el('span', 'ri-notice-text', notice);
|
||
noticeText.id = 'ri-notice-text';
|
||
noticeText.setAttribute('role', 'status');
|
||
noticeText.setAttribute('aria-live', 'polite');
|
||
const zoomIn = el(
|
||
'button',
|
||
'data-toggle-chip ri-zoom-in',
|
||
notice,
|
||
t('ZOOM IN'),
|
||
);
|
||
zoomIn.id = 'ri-zoom-in';
|
||
zoomIn.type = 'button';
|
||
zoomIn.title = t('Fly in until the view fits the 1,000 km limit');
|
||
|
||
const strip = el('div', 'ri-block ri-strip', root);
|
||
strip.id = 'ri-strip';
|
||
strip.tabIndex = 0;
|
||
strip.setAttribute('role', 'listbox');
|
||
strip.setAttribute('aria-orientation', 'horizontal');
|
||
strip.setAttribute(
|
||
'aria-label',
|
||
t('Imagery days, newest first · arrows move, S or A and B pin'),
|
||
);
|
||
strip.setAttribute('aria-keyshortcuts', 'A B S');
|
||
const stripEmpty = el('div', 'ri-strip-empty', strip);
|
||
|
||
const hint = el('div', 'ri-block ri-line ri-hint', root);
|
||
hint.id = 'ri-hint';
|
||
|
||
const selection = el('section', 'ri-block ri-selection', root);
|
||
selection.id = 'ri-selection';
|
||
selection.setAttribute('aria-label', t('On the map'));
|
||
const modeGroup = el('div', 'gev-segmented ri-mode', selection);
|
||
modeGroup.id = 'ri-mode';
|
||
modeGroup.setAttribute('role', 'radiogroup');
|
||
modeGroup.setAttribute('aria-label', t('Mode'));
|
||
const modeButtons = new Map(
|
||
MODE_LABELS.map(([mode, label]) => {
|
||
const node = el('button', 'gev-segment ri-mode-btn', modeGroup);
|
||
node.type = 'button';
|
||
node.textContent = t(label);
|
||
node.dataset.mode = mode;
|
||
node.setAttribute('role', 'radio');
|
||
return [mode, node];
|
||
}),
|
||
);
|
||
const modeControl = createSegmentedControl({
|
||
buttons: modeButtons.values(),
|
||
valueOf: (button) => button.dataset.mode,
|
||
onChange: (mode) => layer.setMode(mode),
|
||
keyboard: 'arrows',
|
||
keyTarget: modeGroup,
|
||
readValue: () => snapshot.mode,
|
||
listen,
|
||
});
|
||
const slotRows = {};
|
||
for (const slotId of ['a', 'b']) {
|
||
const row = el('div', 'ri-slot', selection);
|
||
row.id = `ri-slot-${slotId}`;
|
||
const tag = el('span', 'ri-slot-tag', row);
|
||
const value = el('span', 'ri-slot-value', row);
|
||
const unpin = el('button', 'data-toggle-chip ri-unpin', row, '×');
|
||
unpin.type = 'button';
|
||
unpin.id = `ri-unpin-${slotId}`;
|
||
unpin.dataset.unpin = slotId;
|
||
slotRows[slotId] = { row, tag, value, unpin };
|
||
}
|
||
|
||
const controls = el('div', 'ri-block ri-controls', root);
|
||
controls.id = 'ri-controls';
|
||
const opacityLabel = el('label', 'ri-opacity-label', controls);
|
||
el('span', '', opacityLabel, t('OPACITY'));
|
||
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
|
||
const opacity = el('input', 'ri-opacity', controls);
|
||
opacity.id = 'ri-opacity';
|
||
Object.assign(opacity, {
|
||
type: 'range',
|
||
min: '0',
|
||
max: '100',
|
||
step: '1',
|
||
value: '100',
|
||
});
|
||
opacity.setAttribute('aria-label', t('Imagery opacity'));
|
||
opacityLabel.htmlFor = 'ri-opacity';
|
||
// Its slot is always there; it is only usable while a swipe is live.
|
||
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
|
||
swap.id = 'ri-swap';
|
||
swap.type = 'button';
|
||
swap.title = t('Trade the two sides of the divider');
|
||
const exportHost = el('div', 'ri-exports', controls);
|
||
const exports = createRailCardBlocks({
|
||
container: exportHost,
|
||
cardId: 'recent-imagery',
|
||
onParams: () => {},
|
||
});
|
||
|
||
const detailsHost = el('div', 'ri-details', root);
|
||
detailsHost.id = 'ri-details';
|
||
let detailsOpen = false;
|
||
const details = createRailCards({
|
||
container: detailsHost,
|
||
document,
|
||
cardClassName: 'ri-details-card',
|
||
onOpen: () => {
|
||
detailsOpen = !detailsOpen;
|
||
render();
|
||
// After the render: preserveScroll() puts the old position back.
|
||
if (detailsOpen) revealCard(container, detailsHost.children[0]);
|
||
},
|
||
});
|
||
|
||
container.appendChild(root);
|
||
|
||
// ---- state ------------------------------------------------------------
|
||
const cards = new Map();
|
||
let stripKeys = '';
|
||
let snapshot = layer.getSnapshot();
|
||
let split = null;
|
||
let splitSignature = '';
|
||
let exportError = null;
|
||
const exporting = new Set();
|
||
let destroyed = false;
|
||
|
||
/**
|
||
* 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 renderActions() {
|
||
actions.update([
|
||
{
|
||
id: 'box',
|
||
type: 'actions',
|
||
actions: [
|
||
{
|
||
id: 'select-box',
|
||
label: t('SELECT BOX'),
|
||
title: t('Drag a box on the map (Esc cancels)'),
|
||
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();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
]);
|
||
const selectBox = findIn(
|
||
actionsHost,
|
||
(node) => node.dataset?.actionId === 'select-box',
|
||
);
|
||
if (selectBox) {
|
||
attribute(
|
||
selectBox,
|
||
'aria-pressed',
|
||
String(Boolean(snapshot.toolActive)),
|
||
);
|
||
toggle(selectBox, 'active', snapshot.toolActive);
|
||
}
|
||
}
|
||
|
||
// ---- notice -----------------------------------------------------------
|
||
function renderNotice() {
|
||
const warning = snapshot.boxError || snapshot.error || exportError;
|
||
const text = localizeNotice(
|
||
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : ''),
|
||
);
|
||
set(noticeText, 'textContent', text);
|
||
attribute(noticeText, 'title', text);
|
||
toggle(notice, 'warn', Boolean(warning));
|
||
toggle(notice, 'info', !warning && Boolean(text));
|
||
// ZOOM IN only for the oversized box or view the notice is showing.
|
||
const fit = Boolean(snapshot.zoomToFit);
|
||
toggle(notice, 'has-action', fit);
|
||
toggle(zoomIn, 'is-reserved', !fit);
|
||
set(zoomIn, 'disabled', !fit);
|
||
attribute(zoomIn, 'aria-hidden', String(!fit));
|
||
}
|
||
|
||
// ---- strip ------------------------------------------------------------
|
||
function buildCard(candidate) {
|
||
const card = el('div', 'ri-card');
|
||
card.dataset.key = candidate.key;
|
||
card.setAttribute('role', 'option');
|
||
const entry = { card, image: null, imageUrl: null };
|
||
entry.thumb = el('div', 'ri-thumb', card);
|
||
entry.placeholder = el('span', 'ri-thumb-text', entry.thumb);
|
||
entry.flag = el('span', 'ri-card-flag', entry.thumb);
|
||
entry.start = el('span', 'ri-card-start', entry.thumb, t('START HERE'));
|
||
entry.date = el('div', 'ri-card-date', card);
|
||
entry.sensor = el('div', 'ri-card-sensor', card);
|
||
entry.cloud = el('div', 'ri-card-cloud', card);
|
||
const chips = el('div', 'ri-card-chips', card);
|
||
entry.chips = {};
|
||
for (const slotId of ['a', 'b']) {
|
||
const chip = el('button', 'data-toggle-chip ri-chip', chips);
|
||
chip.type = 'button';
|
||
chip.tabIndex = -1;
|
||
chip.dataset.slot = slotId;
|
||
entry.chips[slotId] = chip;
|
||
}
|
||
cards.set(candidate.key, entry);
|
||
return card;
|
||
}
|
||
|
||
function updateCard(candidate, index) {
|
||
const entry = cards.get(candidate.key);
|
||
if (!entry) return;
|
||
const { card } = entry;
|
||
const day = displayDay(candidate.day);
|
||
const ab = snapshot.mode === 'ab';
|
||
set(card, 'id', `ri-card-${index}`);
|
||
const focused = index === snapshot.focusIndex;
|
||
toggle(card, 'focused', focused);
|
||
attribute(card, 'aria-selected', String(focused));
|
||
toggle(card, 'pinned', Boolean(candidate.pinned));
|
||
toggle(card, 'preview', Boolean(candidate.preview));
|
||
const status = candidate.thumbnail?.status || 'unknown';
|
||
const objectUrl = candidate.thumbnail?.objectUrl || null;
|
||
const shown = status === 'present' && Boolean(objectUrl);
|
||
if (shown && !entry.image) {
|
||
entry.image = el('img', 'ri-thumb-img');
|
||
entry.image.alt = '';
|
||
entry.image.decoding = 'async';
|
||
entry.thumb.insertBefore(entry.image, entry.thumb.children[0] || null);
|
||
}
|
||
if (entry.image && entry.imageUrl !== (shown ? objectUrl : null)) {
|
||
entry.imageUrl = shown ? objectUrl : null;
|
||
entry.image.src = entry.imageUrl || '';
|
||
}
|
||
if (entry.image) set(entry.image, 'hidden', !shown);
|
||
set(entry.placeholder, 'hidden', shown);
|
||
set(
|
||
entry.placeholder,
|
||
'textContent',
|
||
PLACEHOLDERS[status] ? t(PLACEHOLDERS[status]) : t('Checking'),
|
||
);
|
||
if (entry.thumb.dataset.status !== status)
|
||
entry.thumb.dataset.status = status;
|
||
const flag = candidate.preview
|
||
? t('PREVIEW')
|
||
: candidate.pending
|
||
? t('LOADING')
|
||
: '';
|
||
set(entry.flag, 'textContent', flag);
|
||
set(entry.flag, 'hidden', !flag);
|
||
const recommended = snapshot.recommended?.key === candidate.key;
|
||
set(entry.start, 'hidden', !recommended);
|
||
if (recommended)
|
||
set(
|
||
entry.start,
|
||
'title',
|
||
t(
|
||
START_HERE_TITLES[snapshot.recommended.reason] ||
|
||
msg('Newest day with imagery for this box'),
|
||
),
|
||
);
|
||
set(entry.date, 'textContent', day);
|
||
set(entry.sensor, 'textContent', sensorLine(candidate.product));
|
||
set(entry.cloud, 'textContent', cloudText(candidate));
|
||
const empty = status === 'empty';
|
||
for (const slotId of ['a', 'b']) {
|
||
const chip = entry.chips[slotId];
|
||
const live = slotId === 'a' || ab;
|
||
const on = candidate.pinned === slotId && live;
|
||
const label = slotId === 'a' ? (ab ? 'A' : t('SHOW')) : 'B';
|
||
set(chip, 'textContent', label);
|
||
set(chip, 'disabled', empty || !live);
|
||
toggle(chip, 'active', on);
|
||
toggle(chip, 'is-reserved', !live);
|
||
attribute(chip, 'aria-pressed', String(on));
|
||
attribute(chip, 'aria-hidden', String(!live));
|
||
attribute(
|
||
chip,
|
||
'aria-label',
|
||
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')
|
||
: '';
|
||
attribute(
|
||
card,
|
||
'aria-label',
|
||
[
|
||
day,
|
||
sensorLine(candidate.product),
|
||
cloudText(candidate),
|
||
...[recommended ? t('start here') : '', state].filter(Boolean),
|
||
].join(' · '),
|
||
);
|
||
}
|
||
|
||
function renderStrip() {
|
||
const candidates = snapshot.candidates;
|
||
const keys = candidates.map((candidate) => candidate.key).join('|');
|
||
if (keys !== stripKeys) {
|
||
stripKeys = keys;
|
||
const wanted = new Set(candidates.map((candidate) => candidate.key));
|
||
for (const [key, entry] of [...cards]) {
|
||
if (wanted.has(key)) continue;
|
||
entry.card.remove();
|
||
cards.delete(key);
|
||
}
|
||
candidates.forEach((candidate, index) => {
|
||
const node = cards.get(candidate.key)?.card || buildCard(candidate);
|
||
if (strip.children[index] !== node)
|
||
strip.insertBefore(node, strip.children[index] || null);
|
||
});
|
||
}
|
||
candidates.forEach(updateCard);
|
||
set(
|
||
stripEmpty,
|
||
'textContent',
|
||
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'),
|
||
);
|
||
set(stripEmpty, 'hidden', candidates.length > 0);
|
||
if (candidates[snapshot.focusIndex])
|
||
attribute(
|
||
strip,
|
||
'aria-activedescendant',
|
||
`ri-card-${snapshot.focusIndex}`,
|
||
);
|
||
else strip.removeAttribute('aria-activedescendant');
|
||
toggle(strip, 'searching', snapshot.searching);
|
||
set(hint, 'textContent', hintText(snapshot));
|
||
attribute(hint, 'title', hint.textContent);
|
||
}
|
||
|
||
/**
|
||
* 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 ? cards.get(focus.key)?.card : 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;
|
||
snapshot.candidates.forEach((candidate, index) => {
|
||
const card = cards.get(candidate.key)?.card;
|
||
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 renderSelection() {
|
||
const { mode } = snapshot;
|
||
set(selection.dataset, 'mode', mode);
|
||
modeControl.update(mode, { rovingFocus: true });
|
||
const ab = mode === 'ab';
|
||
for (const slotId of ['a', 'b']) {
|
||
const { row, tag, value, unpin } = slotRows[slotId];
|
||
const basemapRow = slotId === 'b' && !ab;
|
||
const image = basemapRow ? null : slotImage(slotId);
|
||
const pinned = Boolean(image && !image.preview);
|
||
set(
|
||
tag,
|
||
'textContent',
|
||
ab ? slotId.toUpperCase() : slotId === 'a' ? t('IMAGE') : t('VS'),
|
||
);
|
||
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(' · ');
|
||
set(value, 'textContent', text);
|
||
attribute(value, 'title', text);
|
||
const lit = basemapRow
|
||
? mode === 'basemap'
|
||
: Boolean(image) && !image.sourceOff;
|
||
set(
|
||
row.dataset,
|
||
'state',
|
||
pinned ? 'pinned' : image ? 'preview' : 'empty',
|
||
);
|
||
toggle(row, 'lit', lit);
|
||
set(unpin, 'disabled', !pinned);
|
||
toggle(unpin, 'is-reserved', !pinned);
|
||
attribute(unpin, 'aria-hidden', String(!pinned));
|
||
attribute(
|
||
unpin,
|
||
'aria-label',
|
||
ab
|
||
? t('Unpin {slot}', { slot: slotId.toUpperCase() })
|
||
: t('Unpin the image'),
|
||
);
|
||
set(unpin, 'title', unpin.getAttribute('aria-label'));
|
||
}
|
||
}
|
||
|
||
// ---- 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 renderControls() {
|
||
const anything = Boolean(snapshot.shown.a || snapshot.shown.b);
|
||
set(opacity, 'disabled', !anything);
|
||
const alphaValue = String(Math.round(snapshot.alpha * 100));
|
||
if (opacity.value !== alphaValue) opacity.value = alphaValue;
|
||
set(opacityValue, 'textContent', `${alphaValue}%`);
|
||
const swipe = snapshot.comparison.active;
|
||
toggle(swap, 'is-reserved', !swipe);
|
||
set(swap, 'disabled', !swipe);
|
||
attribute(swap, 'aria-hidden', String(!swipe));
|
||
toggle(swap, 'active', swipe && snapshot.swapped);
|
||
attribute(swap, 'aria-pressed', String(swipe && snapshot.swapped));
|
||
const ab = snapshot.mode === 'ab';
|
||
exports.update([
|
||
{
|
||
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),
|
||
onClick: () => void exportImage(slotId),
|
||
})),
|
||
},
|
||
]);
|
||
const exportB = findIn(
|
||
exportHost,
|
||
(node) => node.dataset?.actionId === 'export-b',
|
||
);
|
||
if (exportB) {
|
||
toggle(exportB, 'is-reserved', !ab);
|
||
attribute(exportB, 'aria-hidden', String(!ab));
|
||
}
|
||
}
|
||
|
||
// ---- 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 renderDetails() {
|
||
const hidden = snapshot.hiddenCount;
|
||
const showing = snapshot.showUnavailable;
|
||
details.update([
|
||
{
|
||
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),
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
]);
|
||
}
|
||
|
||
// ---- 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,
|
||
parent: document.body,
|
||
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);
|
||
set(root, '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;
|
||
preserveScroll(() => {
|
||
set(root.dataset, 'mode', snapshot.mode);
|
||
renderPanel();
|
||
renderActions();
|
||
renderNotice();
|
||
renderStrip();
|
||
renderSelection();
|
||
renderControls();
|
||
renderDetails();
|
||
renderSplit();
|
||
reportVisibleRange();
|
||
});
|
||
}
|
||
|
||
// ---- export -----------------------------------------------------------
|
||
async function exportImage(slotId = 'a') {
|
||
const candidate = exportTarget(slotId);
|
||
if (!candidate || !snapshot.box || exporting.has(slotId)) return false;
|
||
exporting.add(slotId);
|
||
exportError = null;
|
||
preserveScroll(renderControls);
|
||
// 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(() => {
|
||
renderControls();
|
||
renderNotice();
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---- 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(renderControls);
|
||
} 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.
|
||
preserveScroll(() => {
|
||
snapshot.candidates.forEach(updateCard);
|
||
renderDetails();
|
||
});
|
||
} 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 entry of cards.values()) {
|
||
if (entry.image) entry.image.src = '';
|
||
}
|
||
cards.clear();
|
||
actions.destroy();
|
||
exports.destroy();
|
||
details.destroy();
|
||
root.remove();
|
||
if (count) count.textContent = '';
|
||
if (panel) panel.hidden = true;
|
||
},
|
||
};
|
||
}
|