doxa-maps/library/components/info/MapInfo.vue
Doxa Maps 3b0fb958e1 MapInfo: the one-time nudge is desktop-only, at a measured threshold
The "What can this map do?" pointer is anchored beside the ⓘ and extends ~213px
INWARD, across whatever the tab puts in its top strip. Worst overlap per width,
measured on Deep Research on the shared browser, on 99c22caf7:

     390   Regions "Region Dashboards" 1416px^2   UPG-100 1428px^2
     768   Cities legend 6560   Gospel legend 6560 + input 6240   Regions 4200
     900   Cities legend 2432   Gospel legend 2496 + input 2176   Regions 3696
    1024   Cities CLEAN         Gospel CLEAN                      Regions 2100
    1200   CLEAN                CLEAN                             CLEAN
    1440   CLEAN                CLEAN                             CLEAN

★ IT IS NOT A PHONE PROBLEM, which is how it was first reported and how I first
fixed it. It persists to 1024 and only clears near 1200, so the max-width:600px
version I wrote first would have left 768, 900 and 1024 broken on four tabs.
Two things found that: gospel counted PANELS (.gm-legend/.gm-sheet) as well as
controls -- a control-only probe misses a legend entirely -- and upg100 measured
the same shape on their own tab (768 over "Prayer Resources" and "Map", 1024 over
"Map", clear at 1200+), in en and ar.

★ ARABIC IS MEASURED, not argued. My first attempt used ?lang=ar, which does not
switch the locale -- the page stayed dir=ltr and I reported that run as invalid
rather than as an ar pass. Via DX Life's /ar/ route (dir=rtl confirmed) the
pointer is hidden and clean at 390, 768 and 1024, and at 1440 it mirrors to the
start side (x=104) and is clean there too.

★ TWO OTHER SHAPES WERE MEASURED FIRST AND BOTH WERE WORSE. Stacking it below the
button traded one collision for five: Gospel's "Resources found" chip (2010px^2)
plus the zoom +/- of Prayer Progress, Big Picture and Simple Research. Hiding
only its text shrank it to 30px but still left 675px^2 on "Region Dashboards" and
900px^2 on Gospel's search input.

⚠ NOT A PLACEMENT CHANGE, and an earlier draft of this got it wrong. It also
pinned the button's inline side at narrow widths; studio's pos.narrow values have
since landed and the buttons now sit at x=8, 12, 294 and 346 across the seven
tabs at 390. A pinned inset-inline-* here wins by specificity and would have
silently flattened every one of them. The button is placement's business; this
rule is only about the hint. 0 added lines declare --map-info-side; 0 touch
.mi-btn.

Its own block rather than the existing 600px one, which also full-screens the
dialog and has nothing to do with this.

VERIFIED AFTER THE CHANGE, en and ar: pointer hidden and 0 overlaps at 390, 768
and 1024 on Cities, Gospel, Regions and UPG-100; present and clean at 1200 and
1440.

NOT FIXED HERE: at 1440 Big Picture's pointer lies over the Religion filter by
1050px^2. Stacking it below the button there does not clear it -- it relocates it
to "Adopted" 561 + "1%" 425. The only single rule that clears that too is hiding
the pointer at every width, which deletes the affordance where it does fit. So
BP-1440 wants a per-tab placement value or a known-minor ruling; it is
pointer-events:none, so the filter still opens.
2026-09-29 22:45:49 +00:00

413 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* MapInfo.vue — a map's "what can this do for you?" button, one-time pointer and example popup.
*
* The popup answers the question with REAL output the map makes.
*
* WHY: a map shown to someone with nobody beside them to explain it has to explain itself. The
* popup does that by showing what you walk away with (a poster, a prayer packet, a deck of cards, a
* spreadsheet), pre-generated, so it opens instantly instead of rendering for seconds.
*
* ★ EXAMPLES ARE A LIST, NOT A FIXED POSTER/PACKET/CARDS TRIPLE. Maps make different things: the
* UPG-100 and Regions maps make posters, packets and cards; Big Picture makes only a spreadsheet
* (its export is a CSV and it has no poster or packet anywhere). A fixed triple would promise a
* button a map does not have. Each item is one artefact, in the order given:
* { kind: 'image', src, full?, alt?, titleKey? } a poster or any single picture; `full` opens
* the full-size file in a new tab
* { kind: 'pages', pages: [src, …], titleKey? } the first page; expands to every page and back
* { kind: 'deck', cards: [src, …], titleKey? } a stacked deck; expands to every card and back
* { kind: 'table', columns: [..], rows: [[..]..], total?, titleKey? }
* a small real excerpt of a spreadsheet; `total`
* says how many rows the full export has. A
* column is a ready string or { key } — pass the
* table's own header keys so an Arabic reader does
* not get English headers. Number cells are
* formatted in the reader's locale.
* Relative srcs resolve against `assetsBase`, so the same list works in any tree.
*
* ★ THE POINTER GOES FOR GOOD, per map, once its popup has been opened AND closed (opening alone
* could be a mis-tap). The checkbox in the popup turns the pointer off on EVERY map. Both live in
* localStorage, read through a guard: private browsing and sandboxed frames throw on access, and a
* hint must never be the thing that breaks a page. The button itself always stays.
*
* ★ TEXT. The component's own words are in the 'mapInfo' catalogue area, in all eleven locales, and
* so are the per-map words, as mapInfo.tab.<tabId>.lead / .takeaway / .note, passed in as KEYS (ready
* strings also work). NOT in each map's own area: a bundle ships only the catalogue areas its source
* names (internal/i18n-areas.mjs), and the gallery that mounts one of these per map names none of the
* maps' areas, so a key there would render blank; naming them would pull every map's whole catalogue
* into the gallery. Everything is resolved at render, never stored, so a live language switch
* re-translates it. A host without vue-i18n has only the page-global locale, which
* nothing observes; pass `locale` from a host that tracks it, and the popup also re-reads the
* global every time it opens.
*
* Usage (one per map, inside a positioned container):
* <MapInfo storage-key="upg-100" lead-key="…" :examples="[…]" :assets-base="assetsBase" />
*/
import { ref, reactive, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { tStatic, getRuntimeLocale } from '@map/i18n/index.js'
const props = defineProps({
/** One per map. Keys the "this map's pointer has been seen" flag. */
storageKey: { type: String, required: true },
/** The map's own sentence: what it does for the reader. A catalogue key, or a ready string. */
leadKey: { type: String, default: '' },
lead: { type: String, default: '' },
/** What the reader takes away. Key or string, optional. */
takeawayKey: { type: String, default: '' },
takeaway: { type: String, default: '' },
/** A caveat under the examples (e.g. which countries are not supported yet). Key or string. */
noteKey: { type: String, default: '' },
note: { type: String, default: '' },
/** The artefacts, in display order. See the header for the four kinds. */
examples: { type: Array, default: () => [] },
/** Base for relative example srcs, normally the tree's assets folder. */
assetsBase: { type: String, default: '' },
/** Where the button sits in its container. */
placement: { type: String, default: 'top-right', validator: (v) => ['top-right', 'top-left'].includes(v) },
/** The host's current locale, when it tracks one. */
locale: { type: String, default: '' },
})
// ── locale and text ──────────────────────────────────────────────────────────────────────────────
// `opens` is read by every lookup, so opening the popup re-reads the page-global locale on hosts
// with no reactive one.
const opens = ref(0)
function lang() {
void opens.value
return String(props.locale || getRuntimeLocale() || 'en').split('-')[0].toLowerCase()
}
const t = useOptionalT('', { fallback: (q) => tStatic(q, lang()) })
const text = (key, str) => (key ? t(key) : str)
const num = (n) => { try { return new Intl.NumberFormat(lang()).format(n) } catch { return String(n) } }
const isRtl = () => lang() === 'ar'
// ── storage, guarded ─────────────────────────────────────────────────────────────────────────────
const SEEN = 'dxmaps.mapInfo.seen.'
const OFF = 'dxmaps.mapInfo.pointer'
const OFF_EVENT = 'dxmaps:mapinfo-pointer-off'
function readStore(k) { try { return window.localStorage.getItem(k) } catch { return null } }
function writeStore(k, v) { try { window.localStorage.setItem(k, v) } catch { /* private mode: in-memory only */ } }
const pointing = ref(false)
const pointerOff = ref(false)
function refreshPointer() {
pointerOff.value = readStore(OFF) === 'off'
pointing.value = !pointerOff.value && readStore(SEEN + props.storageKey) !== '1'
}
// Another map on the page (same document) or another tab (storage event) turned the pointer off.
function onPointerOff() { refreshPointer() }
function onStorage(e) { if (!e || e.key === OFF || e.key === SEEN + props.storageKey) refreshPointer() }
onMounted(() => {
refreshPointer()
window.addEventListener(OFF_EVENT, onPointerOff)
window.addEventListener('storage', onStorage)
})
onBeforeUnmount(() => {
window.removeEventListener(OFF_EVENT, onPointerOff)
window.removeEventListener('storage', onStorage)
})
function setPointerOff(off) {
pointerOff.value = off
writeStore(OFF, off ? 'off' : 'on')
pointing.value = !off && readStore(SEEN + props.storageKey) !== '1'
try { window.dispatchEvent(new CustomEvent(OFF_EVENT)) } catch { /* no window */ }
}
// ── open / close ─────────────────────────────────────────────────────────────────────────────────
const open = ref(false)
const btnEl = ref(null)
const closeEl = ref(null)
const expanded = reactive({})
const failed = reactive({})
async function show() {
opens.value++
open.value = true
await nextTick()
closeEl.value?.focus()
}
async function hide() {
open.value = false
writeStore(SEEN + props.storageKey, '1')
pointing.value = false
await nextTick()
btnEl.value?.focus()
}
// ★ aria-modal PROMISES THE READER THAT TAB STAYS INSIDE, so keep it there: Tab from the last control
// wraps to the first, Shift+Tab from the first to the last. The active element is read from the shadow
// root (document.activeElement would be the host element). Hidden controls are skipped.
const dialogEl = ref(null)
function trapTab(e) {
const box = dialogEl.value
if (!box) return
const items = [...box.querySelectorAll('button, a[href], input, [tabindex]:not([tabindex="-1"])')]
.filter((el) => !el.disabled && el.offsetParent !== null)
if (!items.length) return
const active = box.getRootNode().activeElement
const first = items[0], last = items[items.length - 1]
if (e.shiftKey && (active === first || !box.contains(active))) { e.preventDefault(); last.focus() }
else if (!e.shiftKey && (active === last || !box.contains(active))) { e.preventDefault(); first.focus() }
}
function onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); hide() }
else if (e.key === 'Tab') trapTab(e)
}
function toggle(i) { expanded[i] = !expanded[i] }
// ★ A MISSING EXAMPLE IMAGE DISAPPEARS, QUIETLY. The examples are pre-generated files that ship in the
// tree; if one is absent (a shoot not yet landed, a tree copied without it) the reader must see neither
// a broken-image icon nor an empty frame. An artefact whose lead image fails is dropped whole, title
// and all (failed[i]); a single page or card that fails later just hides itself. The text above the
// examples still reads on its own.
function dropImg(e) { const el = e && e.target; if (el) el.style.display = 'none' }
// ── examples ─────────────────────────────────────────────────────────────────────────────────────
function url(src) {
const s = String(src || '')
if (!s || /^(?:[a-z]+:|\/)/i.test(s) || !props.assetsBase) return s
return props.assetsBase.replace(/\/+$/, '') + '/' + s.replace(/^\.?\//, '')
}
const DEFAULT_TITLE = { image: 'mapInfo.poster', pages: 'mapInfo.packet', deck: 'mapInfo.cards', table: 'mapInfo.spreadsheet' }
function titleOf(ex) { return t(ex.titleKey || DEFAULT_TITLE[ex.kind] || 'mapInfo.example') }
const deckTop = (ex) => (ex.cards || []).slice(0, 5)
const colLabel = (c) => (c && typeof c === 'object' ? (c.key ? t(c.key) : String(c.label || '')) : String(c ?? ''))
useShadowStyles(`
.mi-btn{ position:absolute; z-index:20; top:var(--map-info-top, 12px); width:40px; height:40px;
border-radius:50%; border:1px solid rgba(0,0,0,.18); background:#fff; color:#1f2a44; cursor:pointer;
display:inline-flex; align-items:center; justify-content:center; padding:0;
box-shadow:0 2px 8px rgba(0,0,0,.18); font:inherit; }
.mi-btn:hover{ background:#f3f5fa; }
.mi-btn:focus-visible{ outline:3px solid #2563eb; outline-offset:2px; }
.mi-btn svg{ width:22px; height:22px; }
/* LOGICAL SIDES, so an RTL page mirrors: "right" is the END of the line, which in Arabic is the LEFT.
With a physical right:12px the button and pointer covered the first (active) tab of an Arabic tab
bar, which starts at the right. */
.mi-at-right{ inset-inline-end:var(--map-info-side, 12px); }
.mi-at-left{ inset-inline-start:var(--map-info-side, 12px); }
.mi-pointer{ position:absolute; z-index:20; top:calc(var(--map-info-top, 12px) + 4px);
display:flex; align-items:center; gap:6px; pointer-events:none; white-space:nowrap;
background:#1f2a44; color:#fff; font-size:13px; font-weight:600; line-height:1; padding:8px 12px;
border-radius:999px; box-shadow:0 2px 10px rgba(0,0,0,.25); animation:mi-nudge 1.4s ease-in-out infinite; }
.mi-pointer.mi-at-right{ inset-inline-end:calc(var(--map-info-side, 12px) + 50px); }
.mi-pointer.mi-at-left{ inset-inline-start:calc(var(--map-info-side, 12px) + 50px); flex-direction:row-reverse;
animation-name:mi-nudge-left; }
/* The flex order mirrors by itself under dir=rtl; the arrow glyph and the nudge do not, so flip both
so the pointer still points and moves TOWARD the button. */
.mi[dir="rtl"] .mi-pointer svg{ transform:scaleX(-1); }
.mi[dir="rtl"] .mi-pointer.mi-at-right{ animation-name:mi-nudge-left; }
.mi[dir="rtl"] .mi-pointer.mi-at-left{ animation-name:mi-nudge; }
.mi-pointer svg{ width:16px; height:16px; flex:none; }
@keyframes mi-nudge{ 0%,100%{ transform:translateX(0) } 50%{ transform:translateX(6px) } }
@keyframes mi-nudge-left{ 0%,100%{ transform:translateX(0) } 50%{ transform:translateX(-6px) } }
.mi-scrim{ position:fixed; inset:0; z-index:1000; background:rgba(15,20,35,.55);
display:flex; align-items:center; justify-content:center; padding:24px; }
.mi-dialog{ background:#fff; color:#1b1f2a; width:min(720px, 100%); max-height:min(88vh, 100%);
border-radius:14px; box-shadow:0 20px 60px rgba(0,0,0,.35); display:flex; flex-direction:column;
overflow:hidden; font-size:15px; line-height:1.5; }
.mi-head{ display:flex; align-items:flex-start; gap:12px; padding:18px 20px 12px; border-bottom:1px solid #e6e8ee; }
.mi-title{ margin:0; font-size:19px; line-height:1.3; flex:1; }
.mi-close{ flex:none; width:40px; height:40px; border-radius:50%; border:none; background:#eef1f6;
color:#1b1f2a; cursor:pointer; font-size:22px; line-height:1; }
.mi-close:focus-visible, .mi-toggle:focus-visible, .mi-hit:focus-visible, .mi-full:focus-visible{
outline:3px solid #2563eb; outline-offset:2px; }
.mi-body{ overflow-y:auto; padding:16px 20px 20px; -webkit-overflow-scrolling:touch; }
.mi-lead{ margin:0 0 6px; font-size:16px; }
.mi-takeaway{ margin:0 0 14px; color:#3b4256; }
.mi-takeaway b{ color:#1b1f2a; }
.mi-ex{ margin:18px 0 0; }
.mi-ex h3{ margin:0 0 8px; font-size:15px; text-transform:uppercase; letter-spacing:.04em; color:#556; }
.mi-img{ display:block; width:100%; height:auto; border-radius:8px; background:#f4f5f8;
box-shadow:0 1px 4px rgba(0,0,0,.15); }
.mi-poster{ max-width:480px; margin:0 auto; }
.mi-full{ display:inline-block; margin-top:6px; font-size:14px; color:#1d4ed8; }
.mi-hit{ display:block; width:100%; padding:0; border:none; background:none; cursor:zoom-in; text-align:inherit; }
.mi-hit.mi-open{ cursor:zoom-out; }
.mi-page{ max-width:420px; margin:0 auto; }
.mi-pages .mi-page + .mi-page{ margin-top:12px; }
/* isolation: the shadow card behind the first page sits at z-index:-1, which without its own stacking
context is painted UNDER the dialog's white background, i.e. never seen. */
.mi-stack{ position:relative; isolation:isolate; }
.mi-stack::after{ content:''; position:absolute; inset:6px -6px -6px 6px; z-index:-1; border-radius:8px;
background:#e3e6ee; box-shadow:0 1px 4px rgba(0,0,0,.15); }
.mi-toggle{ display:inline-flex; align-items:center; min-height:44px; margin-top:10px; padding:0 16px;
border-radius:999px; border:1px solid #c9cedb; background:#fff; color:#1b1f2a; font:inherit;
font-size:14px; font-weight:600; cursor:pointer; }
.mi-toggle:hover{ background:#f3f5fa; }
.mi-center{ text-align:center; }
.mi-deck{ position:relative; height:250px; max-width:340px; margin:0 auto; }
.mi-deck .mi-card{ position:absolute; top:10px; width:160px; height:224px; object-fit:cover;
border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.25); background:#f4f5f8; }
.mi-grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(120px, 1fr)); gap:10px; }
.mi-grid .mi-img{ aspect-ratio:5 / 7; object-fit:cover; }
.mi-table-wrap{ overflow-x:auto; border:1px solid #e1e4ec; border-radius:8px; }
.mi-table{ border-collapse:collapse; width:100%; font-size:13px; }
.mi-table th, .mi-table td{ padding:6px 10px; border-bottom:1px solid #eceef3; text-align:start; white-space:nowrap; }
.mi-table th{ background:#f5f6f9; font-weight:600; }
.mi-more{ margin:6px 0 0; font-size:13px; color:#556; }
.mi-note{ margin:18px 0 0; padding:10px 12px; border-radius:8px; background:#fff7e6; color:#5a4300; font-size:14px; }
.mi-foot{ padding:12px 20px; border-top:1px solid #e6e8ee; font-size:14px; }
.mi-foot label{ display:inline-flex; align-items:center; gap:8px; min-height:44px; cursor:pointer; }
.mi-foot input{ width:20px; height:20px; }
/* A phone gets the whole screen: a floating card with margins leaves too little room for a page. */
/* ★ THE ONE-TIME NUDGE IS DESKTOP-ONLY, AND THE THRESHOLD IS MEASURED, NOT CHOSEN. The pointer
is anchored beside the button and extends ~213px INWARD, straight across whatever the tab puts
in its top strip. Measured on Deep Research, on the shared browser, worst overlap per width:
390 Regions "Region Dashboards" 1416px^2 UPG-100 1428px^2
768 Cities legend 6560 Gospel legend 6560 + input 6240 Regions selector 4200
900 Cities legend 2432 Gospel legend 2496 + input 2176 Regions selector 3696
1024 Cities CLEAN Gospel CLEAN Regions selector 2100
1200 CLEAN CLEAN CLEAN
1280 CLEAN CLEAN CLEAN
UPG-100 measures the same shape independently: 768 over "Prayer Resources" AND "Map", 1024 over
"Map", clear at 1200/1280/1440 -- in en AND ar.
So it is not a phone problem, which is how it was first reported and first fixed: it persists
to 1024 and only clears near 1200. A max-width:600px rule would have left 768, 900 and 1024
broken on four tabs -- found because gospel counted PANELS (.gm-legend/.gm-sheet) as well as
controls, which a control-only probe misses entirely.
ARABIC IS MEASURED, not argued: via DX Life's /ar/ route (dir=rtl confirmed), the pointer is
hidden and clean at 390, 768 and 1024, and at 1440 it mirrors to the start side (x=104) and is
clean there too.
THE RULE IS ITS OWN BLOCK, deliberately: the 600px block below also full-screens the dialog,
and this has nothing to do with the phone sheet. The .98 covers fractional viewport widths
under browser zoom.
Hiding it below the measured clear width is the only rule that holds everywhere: the button it
points at is untouched, so nothing becomes unreachable, and a hint that covers a map's own
legend, search box or view switcher is worse than no hint.
Two other shapes were measured and both were worse: stacking it below the button traded one
collision for five (Gospel's "Resources found" 2010px^2, plus the zoom +/- of three tabs), and
hiding only its text still left 675px^2 on "Region Dashboards" and 900px^2 on Gospel's input.
⚠ NOT A PLACEMENT CHANGE. The per-tab pos.narrow values in the shell's tabInfo are measured per
tab (the buttons sit at x=8, 12, 294 and 346 across the seven tabs at 390); an inset-inline-*
pinned here would win by specificity and silently flatten all of them. The button is
placement's business; this rule is only about the hint. */
@media (max-width:1199.98px){
.mi-pointer{ display:none; }
}
@media (max-width:600px){
.mi-scrim{ padding:0; align-items:stretch; }
.mi-dialog{ width:100%; max-height:none; height:100%; border-radius:0; }
.mi-pointer{ font-size:12px; padding:7px 10px; }
.mi-deck{ height:220px; }
.mi-deck .mi-card{ width:136px; height:190px; }
}
/* This component animates (the pointer's nudge), so it carries its own reduced-motion rule where its
styles are injected: a page-level rule cannot reach into a shadow root. */
@media (prefers-reduced-motion: reduce){
.mi-pointer{ animation:none; }
}
`, 'map-info')
</script>
<template>
<div class="mi" :dir="isRtl() ? 'rtl' : 'ltr'" :lang="lang()">
<button ref="btnEl" type="button" class="mi-btn" :class="placement === 'top-left' ? 'mi-at-left' : 'mi-at-right'"
:aria-label="t('mapInfo.open')" :title="t('mapInfo.open')" aria-haspopup="dialog" :aria-expanded="String(open)"
@click="show">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" />
<rect x="11" y="10" width="2" height="7" rx="1" fill="currentColor" />
<circle cx="12" cy="7" r="1.3" fill="currentColor" />
</svg>
</button>
<div v-if="pointing && !open" class="mi-pointer" :class="placement === 'top-left' ? 'mi-at-left' : 'mi-at-right'" aria-hidden="true">
<span>{{ t('mapInfo.pointer') }}</span>
<svg viewBox="0 0 16 16" focusable="false">
<path v-if="placement === 'top-left'" d="M14 8H3M7 3.5 2.5 8 7 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path v-else d="M2 8h11M9 3.5 13.5 8 9 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div v-if="open" class="mi-scrim" @click.self="hide" @keydown="onKey">
<div ref="dialogEl" class="mi-dialog" role="dialog" aria-modal="true" :aria-labelledby="'mi-title-' + storageKey">
<div class="mi-head">
<h2 :id="'mi-title-' + storageKey" class="mi-title">{{ t('mapInfo.title') }}</h2>
<button ref="closeEl" type="button" class="mi-close" :aria-label="t('mapInfo.close')" @click="hide">×</button>
</div>
<div class="mi-body">
<p v-if="leadKey || lead" class="mi-lead">{{ text(leadKey, lead) }}</p>
<p v-if="takeawayKey || takeaway" class="mi-takeaway"><b>{{ t('mapInfo.takeaway') }}</b> {{ text(takeawayKey, takeaway) }}</p>
<section v-for="(ex, i) in examples" v-show="!failed[i]" :key="i" class="mi-ex">
<h3>{{ titleOf(ex) }}</h3>
<template v-if="ex.kind === 'image'">
<img class="mi-img mi-poster" :src="url(ex.src)" :alt="ex.alt || titleOf(ex)" decoding="async" @error="failed[i] = true" />
<div v-if="ex.full" class="mi-center">
<a class="mi-full" :href="url(ex.full)" target="_blank" rel="noopener">{{ t('mapInfo.openFull') }}</a>
</div>
</template>
<template v-else-if="ex.kind === 'pages' && (ex.pages || []).length">
<template v-if="!expanded[i]">
<button type="button" class="mi-hit mi-page" :class="{ 'mi-stack': ex.pages.length > 1 }"
:aria-label="t('mapInfo.expandPages', { n: num(ex.pages.length) })" aria-expanded="false" @click="toggle(i)">
<img class="mi-img" :src="url(ex.pages[0])" :alt="titleOf(ex)" decoding="async" @error="failed[i] = true" />
</button>
<div v-if="ex.pages.length > 1" class="mi-center">
<button type="button" class="mi-toggle" aria-expanded="false" @click="toggle(i)">{{ t('mapInfo.expandPages', { n: num(ex.pages.length) }) }}</button>
</div>
</template>
<template v-else>
<div class="mi-center"><button type="button" class="mi-toggle" aria-expanded="true" @click="toggle(i)">{{ t('mapInfo.collapsePages') }}</button></div>
<div class="mi-pages">
<button v-for="(p, k) in ex.pages" :key="k" type="button" class="mi-hit mi-open mi-page"
:aria-label="t('mapInfo.collapsePages')" @click="toggle(i)">
<img class="mi-img" :src="url(p)" :alt="titleOf(ex) + ' ' + num(k + 1)" loading="lazy" decoding="async" @error="dropImg" />
</button>
</div>
<div class="mi-center"><button type="button" class="mi-toggle" aria-expanded="true" @click="toggle(i)">{{ t('mapInfo.collapsePages') }}</button></div>
</template>
</template>
<template v-else-if="ex.kind === 'deck' && (ex.cards || []).length">
<template v-if="!expanded[i]">
<button type="button" class="mi-hit mi-deck" :aria-label="t('mapInfo.expandDeck', { n: num(ex.cards.length) })"
aria-expanded="false" @click="toggle(i)">
<img v-for="(c, k) in deckTop(ex)" :key="k" class="mi-card" :src="url(c)" alt="" decoding="async" @error="k === 0 ? (failed[i] = true) : dropImg($event)"
:style="{ insetInlineStart: (k * 36) + 'px', transform: 'rotate(' + ((k - 2) * 4) + 'deg)', zIndex: 10 - k }" />
</button>
<div class="mi-center">
<button type="button" class="mi-toggle" aria-expanded="false" @click="toggle(i)">{{ t('mapInfo.expandDeck', { n: num(ex.cards.length) }) }}</button>
</div>
</template>
<template v-else>
<div class="mi-center"><button type="button" class="mi-toggle" aria-expanded="true" @click="toggle(i)">{{ t('mapInfo.collapseDeck') }}</button></div>
<div class="mi-grid">
<img v-for="(c, k) in ex.cards" :key="k" class="mi-img" :src="url(c)" :alt="titleOf(ex) + ' ' + num(k + 1)" loading="lazy" decoding="async" @error="dropImg" />
</div>
<div class="mi-center"><button type="button" class="mi-toggle" aria-expanded="true" @click="toggle(i)">{{ t('mapInfo.collapseDeck') }}</button></div>
</template>
</template>
<template v-else-if="ex.kind === 'table' && (ex.columns || []).length">
<div class="mi-table-wrap">
<table class="mi-table">
<thead><tr><th v-for="(c, k) in ex.columns" :key="k" scope="col">{{ colLabel(c) }}</th></tr></thead>
<tbody><tr v-for="(r, k) in ex.rows || []" :key="k"><td v-for="(v, j) in r" :key="j">{{ typeof v === 'number' ? num(v) : v }}</td></tr></tbody>
</table>
</div>
<p v-if="ex.total && ex.total > (ex.rows || []).length" class="mi-more">
{{ t('mapInfo.moreRows', { n: num(ex.total - (ex.rows || []).length) }) }}
</p>
</template>
</section>
<p v-if="noteKey || note" class="mi-note">{{ text(noteKey, note) }}</p>
</div>
<div class="mi-foot">
<label>
<input type="checkbox" :checked="pointerOff" @change="setPointerOff($event.target.checked)" />
<span>{{ t('mapInfo.pointerOff') }}</span>
</label>
</div>
</div>
</div>
</div>
</template>