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.
413 lines
26 KiB
Vue
413 lines
26 KiB
Vue
<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>
|