On phones (up to 767px) the "What can this map do?" panel opened as a full-screen page: a few lines of text at the top of an empty white screen, reading as a new page rather than a panel. It is now a bottom sheet: - it sizes to its content, up to 70% of the small viewport (svh, so a collapsing URL bar does not resize it), and scrolls inside, with its scroll kept to itself; - the close button is 44px and stays in view in the sheet's header; - the dimmed map above it stays visible, and a tap there closes the panel, as Escape does. Desktop is unchanged. Rebuilt doxa-maps-spa (the only bundle that compiles MapInfo.vue), then tree + SRI.
622 lines
39 KiB
Vue
622 lines
39 KiB
Vue
<script>
|
||
const pointerDoneThisLoad = new Set()
|
||
</script>
|
||
|
||
<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. A map whose ⓘ is
|
||
* its own control (trigger 'control') points ONCE PER PAGE LOAD instead, with no checkbox: see "the
|
||
* page-load pointer" below. What is stored lives 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, watch } 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: '' },
|
||
/**
|
||
* 'floating' (default): this component draws its own button and pointer over its container.
|
||
* 'control': the map draws the button in its own control column (map-controls/MapInfoButton.vue) and
|
||
* this component answers it. Its own button stays as the FALLBACK, shown whenever that control is not
|
||
* actually on screen (see "control mode" below).
|
||
*/
|
||
trigger: { type: String, default: 'floating', validator: (v) => ['floating', 'control'].includes(v) },
|
||
})
|
||
// ★ THE PAGE-LOAD POINTER (control mode). The pointer beside the map's own ⓘ shows ONCE PER PAGE LOAD:
|
||
// the first time that control is on screen in the load. It goes for the rest of the load once the reader
|
||
// opens the popup OR leaves the tab (the pane is hidden or inert), and a full reload shows it again. That
|
||
// is page-load memory (pointerDoneThisLoad), never storage, so the popup offers no checkbox: the
|
||
// every-map "don't point again" box belongs to the floating model, which stores its answer.
|
||
const pageLoad = () => props.trigger === 'control'
|
||
|
||
// ── 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 */ } }
|
||
|
||
// ★ PAGE-LOAD MEMORY: pointerDoneThisLoad, in the plain <script> block at the top of this file, is shared
|
||
// by every MapInfo on the page, so it must live in module scope: the gallery REMOUNTS a heavy tab's MapInfo
|
||
// on each switch back, and component state dies with the instance. It holds the storageKeys whose
|
||
// page-load pointer is done for this load; a full reload starts empty. (It sits ABOVE
|
||
// <script setup> so an ordering check reads it as declared before use; it carries no comment there
|
||
// because the library index takes this component's description from the file's first comment.)
|
||
const pointing = ref(false)
|
||
const pointerOff = ref(false)
|
||
function refreshPointer() {
|
||
pointerOff.value = readStore(OFF) === 'off'
|
||
if (pageLoad()) { pointing.value = !pointerDoneThisLoad.has(props.storageKey); return }
|
||
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. A
|
||
// page-load pointer ignores both: the every-map switch is not offered beside it, and a switch the reader
|
||
// cannot see on this map must not silently take its pointer away.
|
||
function onPointerOff() { if (!pageLoad()) refreshPointer() }
|
||
function onStorage(e) {
|
||
if (pageLoad()) return
|
||
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
|
||
// A page-load pointer is done for this load once the popup has been opened (in memory only).
|
||
if (pageLoad()) pointerDoneThisLoad.add(props.storageKey)
|
||
await nextTick()
|
||
closeEl.value?.focus()
|
||
}
|
||
async function hide() {
|
||
open.value = false
|
||
if (!pageLoad()) writeStore(SEEN + props.storageKey, '1')
|
||
pointing.value = false
|
||
await nextTick()
|
||
// Back to whichever button the reader can see: the map's own control when it is on screen, else ours.
|
||
const back = props.trigger === 'control' && controlShown.value && control.value ? control.value : btnEl.value
|
||
back?.focus()
|
||
}
|
||
|
||
// ── control mode ─────────────────────────────────────────────────────────────────────────────────
|
||
// ★ FAIL-OPEN: THERE IS ALWAYS EXACTLY ONE ⓘ. The map's control (MapInfoButton) announces itself and asks
|
||
// for the popup with composed events that bubble out of the plugin's shadow root to the pane this
|
||
// component sits in. Our own button is hidden ONLY while that control is MEASURED to be usable:
|
||
// connected, a non-zero rect on screen, and in no inert subtree (the composite marks its inactive pane
|
||
// inert, and an inert pane keeps its rects). It is re-measured by IntersectionObserver and ResizeObserver
|
||
// on the control, and by a slow fallback timer. It is NEVER inferred from an unmount event: a detached
|
||
// element cannot bubble anything to the pane. So on the UPG-100 wall (no map on screen), on a tab being
|
||
// switched away and back, or with the column hidden, our button is the one that shows.
|
||
const rootEl = ref(null)
|
||
const control = ref(null)
|
||
const controlShown = ref(false)
|
||
let pane = null, io = null, ro = null, timer = null
|
||
function inert(el) {
|
||
for (let n = el; n; n = n.parentNode || n.host) {
|
||
if (n.nodeType === 1 && (n.hasAttribute('inert') || n.getAttribute('aria-hidden') === 'true')) return true
|
||
}
|
||
return false
|
||
}
|
||
// ★ NO FLASH WHILE THE MAP LOADS. Until a control has announced itself, our button waits: the map draws
|
||
// its column only once it is ready, so showing ours first put the floating ⓘ on screen for a moment and
|
||
// then swapped it for the control. The wait is BOUNDED and counts only while our pane is on screen (a
|
||
// tab that mounts hidden, or waits in an inert pane, keeps its full grace for when it is opened): if no
|
||
// control arrives within CONTROL_GRACE_MS of visible time, our button shows (fail-open). Once a control
|
||
// has announced itself, the measurement below decides alone, so a hidden column still gives our button.
|
||
// A map that knows its control is on its way says so ('dxmaps:mapinfo-pending', { pending: true }), and
|
||
// then the clock is only a BACKSTOP (CONTROL_BACKSTOP_MS, for a failure nobody reported); a map that
|
||
// reports its failure ({ pending: false }) ends the wait at once.
|
||
const CONTROL_GRACE_MS = 30000
|
||
const CONTROL_BACKSTOP_MS = 90000
|
||
let controlPending = false
|
||
const awaitingControl = ref(props.trigger === 'control')
|
||
let graceFrom = 0
|
||
function paneVisible() {
|
||
const p = rootEl.value && rootEl.value.parentElement
|
||
const r = p && p.getBoundingClientRect()
|
||
return !!p && !inert(p) && r.width > 0 && r.height > 0
|
||
}
|
||
function tickGrace() {
|
||
if (!awaitingControl.value) return
|
||
if (!paneVisible()) { graceFrom = 0; return }
|
||
if (!graceFrom) graceFrom = Date.now()
|
||
else if (Date.now() - graceFrom >= (controlPending ? CONTROL_BACKSTOP_MS : CONTROL_GRACE_MS)) awaitingControl.value = false
|
||
}
|
||
function measureControl() {
|
||
tickGrace()
|
||
// Leaving the tab ends a page-load pointer that has already been on screen (see the page-load pointer).
|
||
if (pageLoad() && pointing.value && pointerDoneThisLoad.has(props.storageKey) && !paneVisible()) pointing.value = false
|
||
const c = control.value
|
||
let shown = false
|
||
if (c && c.isConnected && !inert(c)) {
|
||
const r = c.getBoundingClientRect()
|
||
shown = r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth
|
||
if (shown) {
|
||
const n = placeBeside(r), o = ctlAt.value
|
||
if (!n || !o || n.side !== o.side || n.top !== o.top || n.left !== o.left || n.right !== o.right) ctlAt.value = n
|
||
}
|
||
}
|
||
controlShown.value = shown
|
||
}
|
||
// ★ THE POINTER FOLLOWS THE CONTROL, WHEREVER THE MAP PUT IT. The floating pointer is anchored to our
|
||
// own button by CSS; the control lives in the map's shadow root, so its place is MEASURED, against the
|
||
// box our absolutely-placed pointer is laid out in (our offsetParent, the pane, which scrolls: hence
|
||
// scrollTop). It goes on the control's physical inner side: a right-to-left map mirrors its column to
|
||
// the left edge, and that is read from where the control IS, never assumed from the page's direction.
|
||
const GAP = 10
|
||
function placeBeside(r) {
|
||
const cb = rootEl.value && rootEl.value.offsetParent
|
||
if (!cb) return null
|
||
const b = cb.getBoundingClientRect()
|
||
const x0 = b.left + cb.clientLeft, y0 = b.top + cb.clientTop
|
||
const top = Math.round(r.top - y0 + cb.scrollTop + r.height / 2)
|
||
return (r.left + r.width / 2) > (x0 + cb.clientWidth / 2)
|
||
? { side: 'right', top, right: Math.round(x0 + cb.clientWidth - r.left + GAP) }
|
||
: { side: 'left', top, left: Math.round(r.right - x0 + GAP) }
|
||
}
|
||
const ctlAt = ref(null)
|
||
const ctlStyle = () => {
|
||
const p = ctlAt.value
|
||
return p.side === 'right' ? { top: p.top + 'px', right: p.right + 'px' } : { top: p.top + 'px', left: p.left + 'px' }
|
||
}
|
||
const onResize = () => measureControl()
|
||
function adopt(el) {
|
||
if (!el || el === control.value) { measureControl(); return }
|
||
try { io?.disconnect(); ro?.disconnect() } catch { /* already gone */ }
|
||
control.value = el
|
||
awaitingControl.value = false
|
||
try { io = new IntersectionObserver(measureControl); io.observe(el) } catch { io = null }
|
||
try { ro = new ResizeObserver(measureControl); ro.observe(el) } catch { ro = null }
|
||
el.setAttribute('aria-expanded', String(open.value))
|
||
measureControl()
|
||
}
|
||
// Only events that travelled through OUR pane; they stop here, so an outer host never sees them twice.
|
||
const ours = (e) => !!pane && typeof e.composedPath === 'function' && e.composedPath().includes(pane)
|
||
function onControl(e) {
|
||
if (!ours(e)) return
|
||
e.stopPropagation()
|
||
if (e.detail) e.detail.ack = true
|
||
adopt(e.detail && e.detail.el)
|
||
}
|
||
function onPending(e) {
|
||
if (!ours(e)) return
|
||
e.stopPropagation()
|
||
controlPending = !!(e.detail && e.detail.pending)
|
||
if (!controlPending && !control.value) awaitingControl.value = false
|
||
}
|
||
function onOpenRequest(e) {
|
||
if (!ours(e)) return
|
||
e.stopPropagation()
|
||
adopt(e.detail && e.detail.el)
|
||
if (!open.value) show()
|
||
}
|
||
onMounted(() => {
|
||
if (props.trigger !== 'control') return
|
||
pane = rootEl.value && rootEl.value.parentElement
|
||
if (!pane) return
|
||
pane.addEventListener('dxmaps:mapinfo-control', onControl)
|
||
pane.addEventListener('dxmaps:mapinfo-open', onOpenRequest)
|
||
pane.addEventListener('dxmaps:mapinfo-pending', onPending)
|
||
timer = window.setInterval(measureControl, 1500)
|
||
window.addEventListener('resize', onResize)
|
||
// A control that mounted before we did asked into the void; tell it to ask again.
|
||
try { window.dispatchEvent(new CustomEvent('dxmaps:mapinfo-host')) } catch { /* no CustomEvent */ }
|
||
})
|
||
onBeforeUnmount(() => {
|
||
if (pane) {
|
||
pane.removeEventListener('dxmaps:mapinfo-control', onControl)
|
||
pane.removeEventListener('dxmaps:mapinfo-open', onOpenRequest)
|
||
pane.removeEventListener('dxmaps:mapinfo-pending', onPending)
|
||
}
|
||
try { io?.disconnect(); ro?.disconnect() } catch { /* already gone */ }
|
||
if (timer) window.clearInterval(timer)
|
||
window.removeEventListener('resize', onResize)
|
||
})
|
||
watch(open, (v) => { control.value?.setAttribute('aria-expanded', String(v)) })
|
||
const floatingShown = () => props.trigger !== 'control' || (!awaitingControl.value && !controlShown.value)
|
||
// The pointer beside the control has been on screen: this page load has pointed once, so a remount (the
|
||
// gallery rebuilds a heavy tab on every switch back) does not point again. Marked on SCREEN, not on mount:
|
||
// a tab's MapInfo can mount inside a hidden pane.
|
||
watch(() => pageLoad() && pointing.value && !open.value && !floatingShown() && !!ctlAt.value,
|
||
(on) => { if (on) pointerDoneThisLoad.add(props.storageKey) }, { immediate: true })
|
||
// ★ 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) } }
|
||
/* Control mode: top/left/right come from placeBeside (inline), centred on the control by translate,
|
||
which composes with the nudge's transform. Laid out left-to-right on purpose: the arrow's side is the
|
||
control's PHYSICAL side, measured, so the page direction must not flip the row, the glyph or the
|
||
nudge (the span keeps the text's own direction). (0,3,0) so the 1200px rule below does not hide it:
|
||
that rule measured the floating pointer crossing each tab's top strip, and this one is placed and
|
||
measured beside one map's own control instead. */
|
||
.mi .mi-pointer.mi-ctl{ display:flex; direction:ltr; translate:0 -50%; }
|
||
.mi .mi-pointer.mi-ctl-left{ flex-direction:row-reverse; animation-name:mi-nudge-left; }
|
||
.mi .mi-pointer.mi-ctl-right{ animation-name:mi-nudge; }
|
||
.mi[dir="rtl"] .mi-pointer.mi-ctl svg{ transform:none; }
|
||
|
||
/* z-index above every map's own top layer: PinHint's first-visit pill sits at 2147482000, so at 1000 it
|
||
floated bright and clickable over the dimmed backdrop (gospel). The dialog is a child of this fixed,
|
||
z-indexed scrim, so it paints inside the scrim's stacking context, on top of it. The root fix is the
|
||
top layer (<dialog>.showModal()); until then this is the highest layer the page uses. */
|
||
.mi-scrim{ position:fixed; inset:0; z-index:2147483000; 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; }
|
||
}
|
||
|
||
/* A phone gets a BOTTOM SHEET, not a full screen. A full-screen dialog showed a few lines of text at the
|
||
top of an empty white page, and read as a new page rather than a panel over the map. The sheet sizes to
|
||
its content up to 70% of the SMALL viewport (svh: a collapsing URL bar must not resize it), scrolls
|
||
inside with its scroll kept to itself, keeps a 44px close button in view, and leaves the dimmed map
|
||
above it, where a tap closes it (the scrim's @click.self), like Escape does. */
|
||
@media (max-width:767.98px){
|
||
.mi-scrim{ padding:0; align-items:flex-end; }
|
||
.mi-dialog{ width:100%; max-height:70vh; max-height:70svh; border-radius:16px 16px 0 0; }
|
||
.mi-head{ align-items:center; padding:10px 10px 10px 20px; padding-inline:20px 10px; }
|
||
.mi-close{ width:44px; height:44px; }
|
||
.mi-body{ overscroll-behavior:contain; padding-bottom:calc(20px + env(safe-area-inset-bottom, 0px)); }
|
||
}
|
||
@media (max-width:600px){
|
||
.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, .mi .mi-pointer.mi-ctl{ animation:none; }
|
||
}
|
||
`, 'map-info')
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="rootEl" class="mi" :dir="isRtl() ? 'rtl' : 'ltr'" :lang="lang()">
|
||
<button v-show="floatingShown()" 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 && floatingShown()" 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>
|
||
<!-- Control mode: the same pointer, beside the map's own control (placed by measurement, placeBeside). -->
|
||
<div v-if="pointing && !open && !floatingShown() && ctlAt" class="mi-pointer mi-ctl" :class="'mi-ctl-' + ctlAt.side" :style="ctlStyle()" aria-hidden="true">
|
||
<span :dir="isRtl() ? 'rtl' : 'ltr'">{{ t('mapInfo.pointer') }}</span>
|
||
<svg viewBox="0 0 16 16" focusable="false">
|
||
<path v-if="ctlAt.side === '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 v-if="!pageLoad()" 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>
|