mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(ui): render the Recent imagery readout as components
createRecentImageryPanel() computes the readout from the layer snapshot and renders RecentImageryReadout and its day cards synchronously; its delegated listeners stay on the stable root and the mode buttons keep their segmented control. Rail card actions take optional pressed and reserved states, replacing the owner's patches after render. Thumbnails, focus, scroll preservation, the DETAILS reveal and export behave as before, and nothing paints after teardown.
This commit is contained in:
1 parent
a318376203
commit
e949584c0a
7 files changed
+425
-398
No files matched your search
@@ -172,11 +172,24 @@ function legendView(legend) {
|
||||
>{{ action.label }}</a
|
||||
><button
|
||||
v-else
|
||||
class="data-toggle-chip"
|
||||
:class="[
|
||||
'data-toggle-chip',
|
||||
{ active: action.pressed, 'is-reserved': action.reserved },
|
||||
]"
|
||||
type="button"
|
||||
:data-action-id="action.id"
|
||||
:title="action.title || undefined"
|
||||
:disabled="Boolean(action.disabled)"
|
||||
:aria-pressed="
|
||||
action.pressed === undefined
|
||||
? undefined
|
||||
: String(Boolean(action.pressed))
|
||||
"
|
||||
:aria-hidden="
|
||||
action.reserved === undefined
|
||||
? undefined
|
||||
: String(Boolean(action.reserved))
|
||||
"
|
||||
@click="dispatch(action, block)"
|
||||
>{{ action.label }}</button
|
||||
><span class="rail-card-action-hint" :hidden="!action.hint">{{
|
||||
|
||||
+174
-395
@@ -4,16 +4,15 @@
|
||||
* 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.
|
||||
* 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 { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import { createSlider } from './slider.js';
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
import RecentImageryReadout from './recentImagery/RecentImageryReadout.vue';
|
||||
import {
|
||||
PRODUCTS,
|
||||
hhmm,
|
||||
@@ -186,16 +185,6 @@ function revealCard(scroller, card) {
|
||||
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
|
||||
@@ -222,88 +211,43 @@ export function createRecentImageryPanel({
|
||||
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'));
|
||||
// ---- 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();
|
||||
|
||||
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];
|
||||
}),
|
||||
);
|
||||
// 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,
|
||||
}),
|
||||
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');
|
||||
const modeGroup = root.querySelector('#ri-mode');
|
||||
const modeControl = createSegmentedControl({
|
||||
buttons: modeButtons.values(),
|
||||
buttons: modeGroup.querySelectorAll('.ri-mode-btn'),
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
onChange: (mode) => layer.setMode(mode),
|
||||
keyboard: 'arrows',
|
||||
@@ -311,74 +255,6 @@ export function createRecentImageryPanel({
|
||||
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 = createSlider({
|
||||
documentRef: document,
|
||||
className: 'ri-opacity',
|
||||
min: '0',
|
||||
max: '100',
|
||||
step: '1',
|
||||
value: '100',
|
||||
ariaLabel: t('Imagery opacity'),
|
||||
});
|
||||
opacity.id = 'ri-opacity';
|
||||
controls.appendChild(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
|
||||
@@ -395,8 +271,8 @@ export function createRecentImageryPanel({
|
||||
}
|
||||
|
||||
// ---- box actions ------------------------------------------------------
|
||||
function renderActions() {
|
||||
actions.update([
|
||||
function actionsView() {
|
||||
return [
|
||||
{
|
||||
id: 'box',
|
||||
type: 'actions',
|
||||
@@ -405,6 +281,7 @@ export function createRecentImageryPanel({
|
||||
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');
|
||||
@@ -432,140 +309,53 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
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() {
|
||||
function noticeView() {
|
||||
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));
|
||||
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 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;
|
||||
function cardView(candidate, index) {
|
||||
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;
|
||||
if (shown) imaged.add(candidate.key);
|
||||
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 chips = ['a', 'b'].map((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
|
||||
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() })
|
||||
@@ -575,40 +365,47 @@ export function createRecentImageryPanel({
|
||||
: candidate.preview
|
||||
? t('preview')
|
||||
: '';
|
||||
attribute(
|
||||
card,
|
||||
'aria-label',
|
||||
[
|
||||
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 renderStrip() {
|
||||
function stripView() {
|
||||
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
|
||||
// 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')
|
||||
@@ -619,27 +416,29 @@ export function createRecentImageryPanel({
|
||||
: !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);
|
||||
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 ? cards.get(focus.key)?.card : null;
|
||||
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;
|
||||
@@ -656,8 +455,9 @@ export function createRecentImageryPanel({
|
||||
const left = Number(strip.scrollLeft) || 0;
|
||||
let first = null;
|
||||
let last = null;
|
||||
const nodes = cardNodes();
|
||||
snapshot.candidates.forEach((candidate, index) => {
|
||||
const card = cards.get(candidate.key)?.card;
|
||||
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;
|
||||
@@ -677,21 +477,13 @@ export function createRecentImageryPanel({
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
function slotsView() {
|
||||
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];
|
||||
return ['a', 'b'].map((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)
|
||||
@@ -706,29 +498,20 @@ export function createRecentImageryPanel({
|
||||
]
|
||||
.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
|
||||
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'),
|
||||
);
|
||||
set(unpin, 'title', unpin.getAttribute('aria-label'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---- opacity and export -----------------------------------------------
|
||||
@@ -739,20 +522,9 @@ export function createRecentImageryPanel({
|
||||
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));
|
||||
function exportsView() {
|
||||
const ab = snapshot.mode === 'ab';
|
||||
exports.update([
|
||||
return [
|
||||
{
|
||||
id: 'export',
|
||||
type: 'actions',
|
||||
@@ -765,18 +537,11 @@ export function createRecentImageryPanel({
|
||||
? 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),
|
||||
})),
|
||||
},
|
||||
]);
|
||||
const exportB = findIn(
|
||||
exportHost,
|
||||
(node) => node.dataset?.actionId === 'export-b',
|
||||
);
|
||||
if (exportB) {
|
||||
toggle(exportB, 'is-reserved', !ab);
|
||||
attribute(exportB, 'aria-hidden', String(!ab));
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// ---- details ----------------------------------------------------------
|
||||
@@ -852,10 +617,10 @@ export function createRecentImageryPanel({
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderDetails() {
|
||||
function detailsView() {
|
||||
const hidden = snapshot.hiddenCount;
|
||||
const showing = snapshot.showUnavailable;
|
||||
details.update([
|
||||
return [
|
||||
{
|
||||
id: 'details',
|
||||
title: t('Details'),
|
||||
@@ -882,7 +647,35 @@ export function createRecentImageryPanel({
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
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 ----------------------------------------------------------
|
||||
@@ -946,7 +739,6 @@ export function createRecentImageryPanel({
|
||||
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);
|
||||
@@ -962,15 +754,12 @@ export function createRecentImageryPanel({
|
||||
|
||||
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(() => {
|
||||
set(root.dataset, 'mode', snapshot.mode);
|
||||
renderPanel();
|
||||
renderActions();
|
||||
renderNotice();
|
||||
renderStrip();
|
||||
renderSelection();
|
||||
renderControls();
|
||||
renderDetails();
|
||||
paint();
|
||||
modeControl.update(snapshot.mode, { rovingFocus: true });
|
||||
renderSplit();
|
||||
reportVisibleRange();
|
||||
});
|
||||
@@ -979,10 +768,11 @@ export function createRecentImageryPanel({
|
||||
// ---- export -----------------------------------------------------------
|
||||
async function exportImage(slotId = 'a') {
|
||||
const candidate = exportTarget(slotId);
|
||||
if (!candidate || !snapshot.box || exporting.has(slotId)) return false;
|
||||
if (destroyed || !candidate || !snapshot.box || exporting.has(slotId))
|
||||
return false;
|
||||
exporting.add(slotId);
|
||||
exportError = null;
|
||||
preserveScroll(renderControls);
|
||||
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;
|
||||
@@ -1030,11 +820,7 @@ export function createRecentImageryPanel({
|
||||
return false;
|
||||
} finally {
|
||||
exporting.delete(slotId);
|
||||
if (!destroyed)
|
||||
preserveScroll(() => {
|
||||
renderControls();
|
||||
renderNotice();
|
||||
});
|
||||
if (!destroyed) preserveScroll(paint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1149,18 +935,15 @@ export function createRecentImageryPanel({
|
||||
if (reason === 'split') {
|
||||
split?.setValue(snapshot.split);
|
||||
} else if (reason === 'alpha') {
|
||||
preserveScroll(renderControls);
|
||||
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.
|
||||
preserveScroll(() => {
|
||||
snapshot.candidates.forEach(updateCard);
|
||||
renderDetails();
|
||||
});
|
||||
// takes this in-place path, without the divider or visible range.
|
||||
preserveScroll(paint);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
@@ -1179,14 +962,10 @@ export function createRecentImageryPanel({
|
||||
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();
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -985,6 +985,15 @@ test('opacity drives both images; EXPORT downloads the pin or the preview and EX
|
||||
failing.byId('ri-notice-text').textContent,
|
||||
'Export failed · HTTP 503',
|
||||
);
|
||||
// A retained export after teardown neither fetches nor mounts again.
|
||||
const fetched = f.fetches.length;
|
||||
const container = f
|
||||
.byId('ri-strip')
|
||||
.closest('.recent-imagery-readout').parentElement;
|
||||
f.readout.destroy();
|
||||
assert.equal(await f.readout.exportImage('a'), false);
|
||||
assert.equal(f.fetches.length, fetched);
|
||||
assert.equal(container.querySelector('.recent-imagery-readout'), null);
|
||||
});
|
||||
|
||||
test('DETAILS is a collapsed rail card holding every note, and the empty-days toggle', async () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
// One imagery day in the strip. The owner computes every value; clicks reach
|
||||
// it through the readout's delegated listener.
|
||||
defineProps({
|
||||
card: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="card.id"
|
||||
:class="[
|
||||
'ri-card',
|
||||
{
|
||||
focused: card.focused,
|
||||
pinned: card.pinned,
|
||||
preview: card.preview,
|
||||
},
|
||||
]"
|
||||
:data-key="card.key"
|
||||
role="option"
|
||||
:aria-selected="String(card.focused)"
|
||||
:aria-label="card.ariaLabel"
|
||||
><div class="ri-thumb" :data-status="card.status"
|
||||
><img
|
||||
v-if="card.image"
|
||||
class="ri-thumb-img"
|
||||
alt=""
|
||||
decoding="async"
|
||||
:src="card.image.src"
|
||||
:hidden="card.image.hidden"
|
||||
/><span
|
||||
class="ri-thumb-text"
|
||||
:hidden="Boolean(card.image && !card.image.hidden)"
|
||||
>{{ card.placeholder }}</span
|
||||
><span class="ri-card-flag" :hidden="!card.flag">{{ card.flag }}</span
|
||||
><span
|
||||
class="ri-card-start"
|
||||
:hidden="!card.start"
|
||||
:title="card.start || undefined"
|
||||
>{{ t('START HERE') }}</span
|
||||
></div
|
||||
><div class="ri-card-date">{{ card.date }}</div
|
||||
><div class="ri-card-sensor">{{ card.sensor }}</div
|
||||
><div class="ri-card-cloud">{{ card.cloud }}</div
|
||||
><div class="ri-card-chips"
|
||||
><button
|
||||
v-for="chip in card.chips"
|
||||
:key="chip.slot"
|
||||
type="button"
|
||||
:class="[
|
||||
'data-toggle-chip ri-chip',
|
||||
{ active: chip.active, 'is-reserved': chip.reserved },
|
||||
]"
|
||||
tabindex="-1"
|
||||
:data-slot="chip.slot"
|
||||
:disabled="chip.disabled"
|
||||
:aria-pressed="String(chip.active)"
|
||||
:aria-hidden="String(chip.reserved)"
|
||||
:aria-label="chip.ariaLabel"
|
||||
>{{ chip.label }}</button
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { t } from '../../i18n/index.js';
|
||||
import RailCardBlocks from '../rail/RailCardBlocks.vue';
|
||||
import RailCards from '../rail/RailCards.vue';
|
||||
import { vRangeValue } from '../rail/rangeValue.js';
|
||||
import RecentImageryCard from './RecentImageryCard.vue';
|
||||
|
||||
// Every block exists from the start, in its final order. The owner computes
|
||||
// the view and listens on the root; the mode buttons are painted by its
|
||||
// segmented control.
|
||||
defineProps({
|
||||
view: { type: Object, required: true },
|
||||
modes: { type: Array, required: true },
|
||||
onOpenDetails: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="recent-imagery-readout"
|
||||
:aria-label="t('Recent imagery')"
|
||||
:hidden="view.hidden"
|
||||
:data-mode="view.mode"
|
||||
><div id="ri-actions" class="ri-block ri-actions-row"
|
||||
><RailCardBlocks :blocks="view.actions" card-id="recent-imagery" /></div
|
||||
><div
|
||||
id="ri-notice"
|
||||
:class="[
|
||||
'ri-block ri-line ri-notice',
|
||||
{
|
||||
warn: view.notice.warn,
|
||||
info: view.notice.info,
|
||||
'has-action': view.notice.fit,
|
||||
},
|
||||
]"
|
||||
><span
|
||||
id="ri-notice-text"
|
||||
class="ri-notice-text"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
:title="view.notice.text"
|
||||
>{{ view.notice.text }}</span
|
||||
><button
|
||||
id="ri-zoom-in"
|
||||
:class="[
|
||||
'data-toggle-chip ri-zoom-in',
|
||||
{ 'is-reserved': !view.notice.fit },
|
||||
]"
|
||||
type="button"
|
||||
:title="t('Fly in until the view fits the 1,000 km limit')"
|
||||
:disabled="!view.notice.fit"
|
||||
:aria-hidden="String(!view.notice.fit)"
|
||||
>{{ t('ZOOM IN') }}</button
|
||||
></div
|
||||
><div
|
||||
id="ri-strip"
|
||||
:class="['ri-block ri-strip', { searching: view.strip.searching }]"
|
||||
tabindex="0"
|
||||
role="listbox"
|
||||
aria-orientation="horizontal"
|
||||
:aria-label="
|
||||
t('Imagery days, newest first · arrows move, S or A and B pin')
|
||||
"
|
||||
aria-keyshortcuts="A B S"
|
||||
:aria-activedescendant="view.strip.activeDescendant || undefined"
|
||||
><RecentImageryCard
|
||||
v-for="card in view.strip.cards"
|
||||
:key="card.key"
|
||||
:card="card"
|
||||
/><div class="ri-strip-empty" :hidden="view.strip.cards.length > 0">{{
|
||||
view.strip.empty
|
||||
}}</div></div
|
||||
><div id="ri-hint" class="ri-block ri-line ri-hint" :title="view.hint">{{
|
||||
view.hint
|
||||
}}</div
|
||||
><section
|
||||
id="ri-selection"
|
||||
class="ri-block ri-selection"
|
||||
:aria-label="t('On the map')"
|
||||
:data-mode="view.mode"
|
||||
><div
|
||||
id="ri-mode"
|
||||
class="gev-segmented ri-mode"
|
||||
role="radiogroup"
|
||||
:aria-label="t('Mode')"
|
||||
><button
|
||||
v-for="[mode, label] in modes"
|
||||
:key="mode"
|
||||
class="gev-segment ri-mode-btn"
|
||||
type="button"
|
||||
:data-mode="mode"
|
||||
role="radio"
|
||||
>{{ t(label) }}</button
|
||||
></div
|
||||
><div
|
||||
v-for="slot in view.slots"
|
||||
:id="`ri-slot-${slot.id}`"
|
||||
:key="slot.id"
|
||||
:class="['ri-slot', { lit: slot.lit }]"
|
||||
:data-state="slot.state"
|
||||
><span class="ri-slot-tag">{{ slot.tag }}</span
|
||||
><span class="ri-slot-value" :title="slot.text">{{ slot.text }}</span
|
||||
><button
|
||||
:id="`ri-unpin-${slot.id}`"
|
||||
:class="[
|
||||
'data-toggle-chip ri-unpin',
|
||||
{ 'is-reserved': !slot.pinned },
|
||||
]"
|
||||
type="button"
|
||||
:data-unpin="slot.id"
|
||||
:disabled="!slot.pinned"
|
||||
:aria-hidden="String(!slot.pinned)"
|
||||
:aria-label="slot.unpinLabel"
|
||||
:title="slot.unpinLabel"
|
||||
>×</button
|
||||
></div
|
||||
></section
|
||||
><div id="ri-controls" class="ri-block ri-controls"
|
||||
><label class="ri-opacity-label" for="ri-opacity"
|
||||
><span>{{ t('OPACITY') }}</span
|
||||
><span class="ri-opacity-value">{{ view.alpha }}%</span></label
|
||||
><input
|
||||
id="ri-opacity"
|
||||
v-range-value="view.alpha"
|
||||
type="range"
|
||||
class="gev-quantitative-slider ri-opacity"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
:aria-label="t('Imagery opacity')"
|
||||
:disabled="!view.opacityEnabled" /><button
|
||||
id="ri-swap"
|
||||
:class="[
|
||||
'data-toggle-chip ri-swap',
|
||||
{
|
||||
'is-reserved': !view.swap.live,
|
||||
active: view.swap.pressed,
|
||||
},
|
||||
]"
|
||||
type="button"
|
||||
:title="t('Trade the two sides of the divider')"
|
||||
:disabled="!view.swap.live"
|
||||
:aria-hidden="String(!view.swap.live)"
|
||||
:aria-pressed="String(view.swap.pressed)"
|
||||
>{{ t('SWAP') }}</button
|
||||
><div class="ri-exports"
|
||||
><RailCardBlocks
|
||||
:blocks="view.exports"
|
||||
card-id="recent-imagery" /></div></div
|
||||
><div id="ri-details" class="ri-details"
|
||||
><RailCards
|
||||
:cards="view.details"
|
||||
card-class-name="ri-details-card"
|
||||
:on-open="onOpenDetails" /></div
|
||||
></section>
|
||||
</template>
|
||||
Reference in new issue
Block a user