/** * 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, 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; }, }; }