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