mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
+421
-394
No files matched your search
@@ -82,7 +82,8 @@ They contain no application state.
|
|||||||
Vue's synchronous `render()` behind `createRailCards()`,
|
Vue's synchronous `render()` behind `createRailCards()`,
|
||||||
`createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and
|
`createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and
|
||||||
`SceneShotList` over the `gev-scenes` store), Weather (done: the owner
|
`SceneShotList` over the `gev-scenes` store), Weather (done: the owner
|
||||||
renders `WeatherReadout` from the view it computes), Recent imagery,
|
renders `WeatherReadout` from the view it computes), Recent imagery
|
||||||
|
(done: `RecentImageryReadout` and its day cards, the same way),
|
||||||
Street level, CCTV, Layers, Display, Context and Radio, the command dock,
|
Street level, CCTV, Layers, Display, Context and Radio, the command dock,
|
||||||
the phone shell, then Cockpit. Layers that write into the right rail
|
the phone shell, then Cockpit. Layers that write into the right rail
|
||||||
directly gain a slot instead.
|
directly gain a slot instead.
|
||||||
|
|||||||
@@ -2907,7 +2907,9 @@
|
|||||||
"src/ui/rail/utcClock.js",
|
"src/ui/rail/utcClock.js",
|
||||||
"src/ui/rail/RailTimeline.vue",
|
"src/ui/rail/RailTimeline.vue",
|
||||||
"src/ui/rail/rangeValue.js",
|
"src/ui/rail/rangeValue.js",
|
||||||
"src/ui/weather/WeatherReadout.vue"
|
"src/ui/weather/WeatherReadout.vue",
|
||||||
|
"src/ui/recentImagery/RecentImageryCard.vue",
|
||||||
|
"src/ui/recentImagery/RecentImageryReadout.vue"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -172,11 +172,24 @@ function legendView(legend) {
|
|||||||
>{{ action.label }}</a
|
>{{ action.label }}</a
|
||||||
><button
|
><button
|
||||||
v-else
|
v-else
|
||||||
class="data-toggle-chip"
|
:class="[
|
||||||
|
'data-toggle-chip',
|
||||||
|
{ active: action.pressed, 'is-reserved': action.reserved },
|
||||||
|
]"
|
||||||
type="button"
|
type="button"
|
||||||
:data-action-id="action.id"
|
:data-action-id="action.id"
|
||||||
:title="action.title || undefined"
|
:title="action.title || undefined"
|
||||||
:disabled="Boolean(action.disabled)"
|
: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)"
|
@click="dispatch(action, block)"
|
||||||
>{{ action.label }}</button
|
>{{ action.label }}</button
|
||||||
><span class="rail-card-action-hint" :hidden="!action.hint">{{
|
><span class="rail-card-action-hint" :hidden="!action.hint">{{
|
||||||
|
|||||||
+170
-391
@@ -4,16 +4,15 @@
|
|||||||
* moves, whatever the state: box actions, a notice line, the strip of day
|
* 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),
|
* 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
|
* 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
|
* hedge. The adapter computes the view from the layer's snapshot and renders
|
||||||
* strip and selection bar are this adapter's own keyed DOM. Source toggles
|
* the RecentImageryReadout component; box actions, export and DETAILS are
|
||||||
* live on the data-panel row.
|
* 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 { 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 { createImagerySplit } from './imagerySplit.js';
|
||||||
import { createSlider } from './slider.js';
|
|
||||||
import { createSegmentedControl } from './segmentedControl.js';
|
import { createSegmentedControl } from './segmentedControl.js';
|
||||||
|
import RecentImageryReadout from './recentImagery/RecentImageryReadout.vue';
|
||||||
import {
|
import {
|
||||||
PRODUCTS,
|
PRODUCTS,
|
||||||
hhmm,
|
hhmm,
|
||||||
@@ -186,16 +185,6 @@ function revealCard(scroller, card) {
|
|||||||
if (delta) scroller.scrollTop = (scroller.scrollTop || 0) + delta;
|
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.
|
* 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
|
* @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);
|
target.addEventListener(type, handler, options);
|
||||||
listeners.push([target, 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) => {
|
const set = (node, key, value) => {
|
||||||
if (node[key] !== value) 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 ----
|
// ---- state ------------------------------------------------------------
|
||||||
const root = el('section', 'recent-imagery-readout');
|
let snapshot = layer.getSnapshot();
|
||||||
root.setAttribute('aria-label', t('Recent imagery'));
|
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);
|
// The readout patches synchronously, so it can be measured at once. Once
|
||||||
actionsHost.id = 'ri-actions';
|
// destroyed nothing paints: a late export would mount it again.
|
||||||
const actions = createRailCardBlocks({
|
const paint = () =>
|
||||||
container: actionsHost,
|
!destroyed &&
|
||||||
cardId: 'recent-imagery',
|
renderVue(
|
||||||
onParams: () => {},
|
h(RecentImageryReadout, {
|
||||||
});
|
view: buildView(),
|
||||||
|
modes: MODE_LABELS,
|
||||||
// The notice line: the text, and ZOOM IN in a fixed slot at its right end
|
onOpenDetails,
|
||||||
// (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];
|
|
||||||
}),
|
}),
|
||||||
|
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({
|
const modeControl = createSegmentedControl({
|
||||||
buttons: modeButtons.values(),
|
buttons: modeGroup.querySelectorAll('.ri-mode-btn'),
|
||||||
valueOf: (button) => button.dataset.mode,
|
valueOf: (button) => button.dataset.mode,
|
||||||
onChange: (mode) => layer.setMode(mode),
|
onChange: (mode) => layer.setMode(mode),
|
||||||
keyboard: 'arrows',
|
keyboard: 'arrows',
|
||||||
@@ -311,74 +255,6 @@ export function createRecentImageryPanel({
|
|||||||
readValue: () => snapshot.mode,
|
readValue: () => snapshot.mode,
|
||||||
listen,
|
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
|
* The body is the scroll container and no render may move it: a
|
||||||
@@ -395,8 +271,8 @@ export function createRecentImageryPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- box actions ------------------------------------------------------
|
// ---- box actions ------------------------------------------------------
|
||||||
function renderActions() {
|
function actionsView() {
|
||||||
actions.update([
|
return [
|
||||||
{
|
{
|
||||||
id: 'box',
|
id: 'box',
|
||||||
type: 'actions',
|
type: 'actions',
|
||||||
@@ -405,6 +281,7 @@ export function createRecentImageryPanel({
|
|||||||
id: 'select-box',
|
id: 'select-box',
|
||||||
label: t('SELECT BOX'),
|
label: t('SELECT BOX'),
|
||||||
title: t('Drag a box on the map (Esc cancels)'),
|
title: t('Drag a box on the map (Esc cancels)'),
|
||||||
|
pressed: Boolean(snapshot.toolActive),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
exportError = null;
|
exportError = null;
|
||||||
if (tool?.isActive()) tool.cancel('toggle');
|
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 -----------------------------------------------------------
|
// ---- notice -----------------------------------------------------------
|
||||||
function renderNotice() {
|
function noticeView() {
|
||||||
const warning = snapshot.boxError || snapshot.error || exportError;
|
const warning = snapshot.boxError || snapshot.error || exportError;
|
||||||
const text = localizeNotice(
|
const text = localizeNotice(
|
||||||
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : ''),
|
warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : ''),
|
||||||
);
|
);
|
||||||
set(noticeText, 'textContent', text);
|
return {
|
||||||
attribute(noticeText, 'title', text);
|
text,
|
||||||
toggle(notice, 'warn', Boolean(warning));
|
warn: Boolean(warning),
|
||||||
toggle(notice, 'info', !warning && Boolean(text));
|
info: !warning && Boolean(text),
|
||||||
// ZOOM IN only for the oversized box or view the notice is showing.
|
// ZOOM IN only for the oversized box or view the notice is showing.
|
||||||
const fit = Boolean(snapshot.zoomToFit);
|
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 ------------------------------------------------------------
|
// ---- strip ------------------------------------------------------------
|
||||||
function buildCard(candidate) {
|
function cardView(candidate, index) {
|
||||||
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 day = displayDay(candidate.day);
|
||||||
const ab = snapshot.mode === 'ab';
|
const ab = snapshot.mode === 'ab';
|
||||||
set(card, 'id', `ri-card-${index}`);
|
|
||||||
const focused = index === snapshot.focusIndex;
|
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 status = candidate.thumbnail?.status || 'unknown';
|
||||||
const objectUrl = candidate.thumbnail?.objectUrl || null;
|
const objectUrl = candidate.thumbnail?.objectUrl || null;
|
||||||
const shown = status === 'present' && Boolean(objectUrl);
|
const shown = status === 'present' && Boolean(objectUrl);
|
||||||
if (shown && !entry.image) {
|
if (shown) imaged.add(candidate.key);
|
||||||
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
|
const flag = candidate.preview
|
||||||
? t('PREVIEW')
|
? t('PREVIEW')
|
||||||
: candidate.pending
|
: candidate.pending
|
||||||
? t('LOADING')
|
? t('LOADING')
|
||||||
: '';
|
: '';
|
||||||
set(entry.flag, 'textContent', flag);
|
|
||||||
set(entry.flag, 'hidden', !flag);
|
|
||||||
const recommended = snapshot.recommended?.key === candidate.key;
|
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';
|
const empty = status === 'empty';
|
||||||
for (const slotId of ['a', 'b']) {
|
const chips = ['a', 'b'].map((slotId) => {
|
||||||
const chip = entry.chips[slotId];
|
|
||||||
const live = slotId === 'a' || ab;
|
const live = slotId === 'a' || ab;
|
||||||
const on = candidate.pinned === slotId && live;
|
return {
|
||||||
const label = slotId === 'a' ? (ab ? 'A' : t('SHOW')) : 'B';
|
slot: slotId,
|
||||||
set(chip, 'textContent', label);
|
label: slotId === 'a' ? (ab ? 'A' : t('SHOW')) : 'B',
|
||||||
set(chip, 'disabled', empty || !live);
|
disabled: empty || !live,
|
||||||
toggle(chip, 'active', on);
|
active: candidate.pinned === slotId && live,
|
||||||
toggle(chip, 'is-reserved', !live);
|
reserved: !live,
|
||||||
attribute(chip, 'aria-pressed', String(on));
|
ariaLabel: ab
|
||||||
attribute(chip, 'aria-hidden', String(!live));
|
|
||||||
attribute(
|
|
||||||
chip,
|
|
||||||
'aria-label',
|
|
||||||
ab
|
|
||||||
? t('Pin {day} as {slot}', { day, slot: slotId.toUpperCase() })
|
? t('Pin {day} as {slot}', { day, slot: slotId.toUpperCase() })
|
||||||
: t('Show {day}', { day }),
|
: t('Show {day}', { day }),
|
||||||
);
|
};
|
||||||
}
|
});
|
||||||
const state = candidate.pinned
|
const state = candidate.pinned
|
||||||
? ab
|
? ab
|
||||||
? t('pinned {slot}', { slot: candidate.pinned.toUpperCase() })
|
? t('pinned {slot}', { slot: candidate.pinned.toUpperCase() })
|
||||||
@@ -575,40 +365,47 @@ export function createRecentImageryPanel({
|
|||||||
: candidate.preview
|
: candidate.preview
|
||||||
? t('preview')
|
? t('preview')
|
||||||
: '';
|
: '';
|
||||||
attribute(
|
return {
|
||||||
card,
|
key: candidate.key,
|
||||||
'aria-label',
|
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,
|
day,
|
||||||
sensorLine(candidate.product),
|
sensorLine(candidate.product),
|
||||||
cloudText(candidate),
|
cloudText(candidate),
|
||||||
...[recommended ? t('start here') : '', state].filter(Boolean),
|
...[recommended ? t('start here') : '', state].filter(Boolean),
|
||||||
].join(' · '),
|
].join(' · '),
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStrip() {
|
function stripView() {
|
||||||
const candidates = snapshot.candidates;
|
const candidates = snapshot.candidates;
|
||||||
const keys = candidates.map((candidate) => candidate.key).join('|');
|
// A day that leaves the strip loses its image with its card.
|
||||||
if (keys !== stripKeys) {
|
const keys = new Set(candidates.map((candidate) => candidate.key));
|
||||||
stripKeys = keys;
|
for (const key of imaged) if (!keys.has(key)) imaged.delete(key);
|
||||||
const wanted = new Set(candidates.map((candidate) => candidate.key));
|
return {
|
||||||
for (const [key, entry] of [...cards]) {
|
cards: candidates.map(cardView),
|
||||||
if (wanted.has(key)) continue;
|
empty: candidates.length
|
||||||
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
|
: !snapshot.box
|
||||||
? t('No box')
|
? t('No box')
|
||||||
@@ -619,27 +416,29 @@ export function createRecentImageryPanel({
|
|||||||
: !snapshot.sources.hls && !snapshot.sources.viirs
|
: !snapshot.sources.hls && !snapshot.sources.viirs
|
||||||
? t('Sources off')
|
? t('Sources off')
|
||||||
: t('No imagery'),
|
: t('No imagery'),
|
||||||
);
|
activeDescendant: candidates[snapshot.focusIndex]
|
||||||
set(stripEmpty, 'hidden', candidates.length > 0);
|
? `ri-card-${snapshot.focusIndex}`
|
||||||
if (candidates[snapshot.focusIndex])
|
: null,
|
||||||
attribute(
|
searching: Boolean(snapshot.searching),
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
* Keyboard only: slide the strip until the focused card is in view. Only
|
||||||
* `strip.scrollLeft` moves — `scrollIntoView` would also scroll the body.
|
* `strip.scrollLeft` moves — `scrollIntoView` would also scroll the body.
|
||||||
*/
|
*/
|
||||||
function scrollFocusedIntoView() {
|
function scrollFocusedIntoView() {
|
||||||
const focus = snapshot.candidates[snapshot.focusIndex];
|
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 left = Number(card?.offsetLeft) || 0;
|
||||||
const width = Number(card?.offsetWidth) || 0;
|
const width = Number(card?.offsetWidth) || 0;
|
||||||
const viewLeft = Number(strip.scrollLeft) || 0;
|
const viewLeft = Number(strip.scrollLeft) || 0;
|
||||||
@@ -656,8 +455,9 @@ export function createRecentImageryPanel({
|
|||||||
const left = Number(strip.scrollLeft) || 0;
|
const left = Number(strip.scrollLeft) || 0;
|
||||||
let first = null;
|
let first = null;
|
||||||
let last = null;
|
let last = null;
|
||||||
|
const nodes = cardNodes();
|
||||||
snapshot.candidates.forEach((candidate, index) => {
|
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 start = Number(card?.offsetLeft) || 0;
|
||||||
const end = start + (Number(card?.offsetWidth) || 0);
|
const end = start + (Number(card?.offsetWidth) || 0);
|
||||||
if (!card || end < left || start > left + width) return;
|
if (!card || end < left || start > left + width) return;
|
||||||
@@ -677,21 +477,13 @@ export function createRecentImageryPanel({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSelection() {
|
function slotsView() {
|
||||||
const { mode } = snapshot;
|
const { mode } = snapshot;
|
||||||
set(selection.dataset, 'mode', mode);
|
|
||||||
modeControl.update(mode, { rovingFocus: true });
|
|
||||||
const ab = mode === 'ab';
|
const ab = mode === 'ab';
|
||||||
for (const slotId of ['a', 'b']) {
|
return ['a', 'b'].map((slotId) => {
|
||||||
const { row, tag, value, unpin } = slotRows[slotId];
|
|
||||||
const basemapRow = slotId === 'b' && !ab;
|
const basemapRow = slotId === 'b' && !ab;
|
||||||
const image = basemapRow ? null : slotImage(slotId);
|
const image = basemapRow ? null : slotImage(slotId);
|
||||||
const pinned = Boolean(image && !image.preview);
|
const pinned = Boolean(image && !image.preview);
|
||||||
set(
|
|
||||||
tag,
|
|
||||||
'textContent',
|
|
||||||
ab ? slotId.toUpperCase() : slotId === 'a' ? t('IMAGE') : t('VS'),
|
|
||||||
);
|
|
||||||
let text = t('Not set');
|
let text = t('Not set');
|
||||||
if (basemapRow) text = t('Basemap');
|
if (basemapRow) text = t('Basemap');
|
||||||
else if (image?.label)
|
else if (image?.label)
|
||||||
@@ -706,29 +498,20 @@ export function createRecentImageryPanel({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
set(value, 'textContent', text);
|
return {
|
||||||
attribute(value, 'title', text);
|
id: slotId,
|
||||||
const lit = basemapRow
|
tag: ab ? slotId.toUpperCase() : slotId === 'a' ? t('IMAGE') : t('VS'),
|
||||||
|
text,
|
||||||
|
lit: basemapRow
|
||||||
? mode === 'basemap'
|
? mode === 'basemap'
|
||||||
: Boolean(image) && !image.sourceOff;
|
: Boolean(image) && !image.sourceOff,
|
||||||
set(
|
state: pinned ? 'pinned' : image ? 'preview' : 'empty',
|
||||||
row.dataset,
|
pinned,
|
||||||
'state',
|
unpinLabel: ab
|
||||||
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 {slot}', { slot: slotId.toUpperCase() })
|
||||||
: t('Unpin the image'),
|
: t('Unpin the image'),
|
||||||
);
|
};
|
||||||
set(unpin, 'title', unpin.getAttribute('aria-label'));
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- opacity and export -----------------------------------------------
|
// ---- opacity and export -----------------------------------------------
|
||||||
@@ -739,20 +522,9 @@ export function createRecentImageryPanel({
|
|||||||
return image?.candidate && !image.sourceOff ? image.candidate : null;
|
return image?.candidate && !image.sourceOff ? image.candidate : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderControls() {
|
function exportsView() {
|
||||||
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';
|
const ab = snapshot.mode === 'ab';
|
||||||
exports.update([
|
return [
|
||||||
{
|
{
|
||||||
id: 'export',
|
id: 'export',
|
||||||
type: 'actions',
|
type: 'actions',
|
||||||
@@ -765,18 +537,11 @@ export function createRecentImageryPanel({
|
|||||||
? t('Download {slot} as a PNG', { slot: slotId.toUpperCase() })
|
? t('Download {slot} as a PNG', { slot: slotId.toUpperCase() })
|
||||||
: t('Download the image as a PNG'),
|
: t('Download the image as a PNG'),
|
||||||
disabled: !exportTarget(slotId) || exporting.has(slotId),
|
disabled: !exportTarget(slotId) || exporting.has(slotId),
|
||||||
|
reserved: slotId === 'b' ? !ab : undefined,
|
||||||
onClick: () => void exportImage(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 ----------------------------------------------------------
|
// ---- details ----------------------------------------------------------
|
||||||
@@ -852,10 +617,10 @@ export function createRecentImageryPanel({
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetails() {
|
function detailsView() {
|
||||||
const hidden = snapshot.hiddenCount;
|
const hidden = snapshot.hiddenCount;
|
||||||
const showing = snapshot.showUnavailable;
|
const showing = snapshot.showUnavailable;
|
||||||
details.update([
|
return [
|
||||||
{
|
{
|
||||||
id: 'details',
|
id: 'details',
|
||||||
title: t('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 ----------------------------------------------------------
|
// ---- divider ----------------------------------------------------------
|
||||||
@@ -946,7 +739,6 @@ export function createRecentImageryPanel({
|
|||||||
function renderPanel() {
|
function renderPanel() {
|
||||||
const hidden = !snapshot.enabled;
|
const hidden = !snapshot.enabled;
|
||||||
if (panel) set(panel, 'hidden', hidden);
|
if (panel) set(panel, 'hidden', hidden);
|
||||||
set(root, 'hidden', hidden);
|
|
||||||
if (count) set(count, 'textContent', hidden ? '' : countText(snapshot));
|
if (count) set(count, 'textContent', hidden ? '' : countText(snapshot));
|
||||||
if (hidden || !panel || appearedDocuments.has(document)) return;
|
if (hidden || !panel || appearedDocuments.has(document)) return;
|
||||||
appearedDocuments.add(document);
|
appearedDocuments.add(document);
|
||||||
@@ -962,15 +754,12 @@ export function createRecentImageryPanel({
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (destroyed) return;
|
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(() => {
|
preserveScroll(() => {
|
||||||
set(root.dataset, 'mode', snapshot.mode);
|
|
||||||
renderPanel();
|
renderPanel();
|
||||||
renderActions();
|
paint();
|
||||||
renderNotice();
|
modeControl.update(snapshot.mode, { rovingFocus: true });
|
||||||
renderStrip();
|
|
||||||
renderSelection();
|
|
||||||
renderControls();
|
|
||||||
renderDetails();
|
|
||||||
renderSplit();
|
renderSplit();
|
||||||
reportVisibleRange();
|
reportVisibleRange();
|
||||||
});
|
});
|
||||||
@@ -979,10 +768,11 @@ export function createRecentImageryPanel({
|
|||||||
// ---- export -----------------------------------------------------------
|
// ---- export -----------------------------------------------------------
|
||||||
async function exportImage(slotId = 'a') {
|
async function exportImage(slotId = 'a') {
|
||||||
const candidate = exportTarget(slotId);
|
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);
|
exporting.add(slotId);
|
||||||
exportError = null;
|
exportError = null;
|
||||||
preserveScroll(renderControls);
|
preserveScroll(paint);
|
||||||
// The snapshot is plate carrée: keep the box's degree aspect, with the
|
// The snapshot is plate carrée: keep the box's degree aspect, with the
|
||||||
// longer side at the size limit.
|
// longer side at the size limit.
|
||||||
const size = longerSideKm(snapshot.boxSizeKm) > 25 ? 2048 : 1024;
|
const size = longerSideKm(snapshot.boxSizeKm) > 25 ? 2048 : 1024;
|
||||||
@@ -1030,11 +820,7 @@ export function createRecentImageryPanel({
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
exporting.delete(slotId);
|
exporting.delete(slotId);
|
||||||
if (!destroyed)
|
if (!destroyed) preserveScroll(paint);
|
||||||
preserveScroll(() => {
|
|
||||||
renderControls();
|
|
||||||
renderNotice();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,18 +935,15 @@ export function createRecentImageryPanel({
|
|||||||
if (reason === 'split') {
|
if (reason === 'split') {
|
||||||
split?.setValue(snapshot.split);
|
split?.setValue(snapshot.split);
|
||||||
} else if (reason === 'alpha') {
|
} else if (reason === 'alpha') {
|
||||||
preserveScroll(renderControls);
|
preserveScroll(paint);
|
||||||
} else if (
|
} else if (
|
||||||
reason === 'thumbnail' &&
|
reason === 'thumbnail' &&
|
||||||
snapshot.candidates.map((candidate) => candidate.key).join('|') ===
|
snapshot.candidates.map((candidate) => candidate.key).join('|') ===
|
||||||
stripKeys
|
stripKeys
|
||||||
) {
|
) {
|
||||||
// A probe that emptied a day hides its card; only an unchanged strip
|
// A probe that emptied a day hides its card; only an unchanged strip
|
||||||
// takes this in-place path.
|
// takes this in-place path, without the divider or visible range.
|
||||||
preserveScroll(() => {
|
preserveScroll(paint);
|
||||||
snapshot.candidates.forEach(updateCard);
|
|
||||||
renderDetails();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -1179,14 +962,10 @@ export function createRecentImageryPanel({
|
|||||||
split = null;
|
split = null;
|
||||||
for (const [target, type, handler, options] of listeners.splice(0))
|
for (const [target, type, handler, options] of listeners.splice(0))
|
||||||
target.removeEventListener(type, handler, options);
|
target.removeEventListener(type, handler, options);
|
||||||
for (const entry of cards.values()) {
|
for (const image of root.querySelectorAll('.ri-thumb-img'))
|
||||||
if (entry.image) entry.image.src = '';
|
image.src = '';
|
||||||
}
|
imaged.clear();
|
||||||
cards.clear();
|
renderVue(null, container);
|
||||||
actions.destroy();
|
|
||||||
exports.destroy();
|
|
||||||
details.destroy();
|
|
||||||
root.remove();
|
|
||||||
if (count) count.textContent = '';
|
if (count) count.textContent = '';
|
||||||
if (panel) panel.hidden = true;
|
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,
|
failing.byId('ri-notice-text').textContent,
|
||||||
'Export failed · HTTP 503',
|
'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 () => {
|
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