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:
Sameh Khamis committed 2026-10-30 18:58:32 -07:00
1 parent a318376203
commit e949584c0a
7 files changed
+425 -398

No files matched your search

@@ -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>