A map explains itself: an info button, a one-time pointer and an example popup
MapInfo (library/components/info) answers "what can this map do for you?" with real output the map makes, pre-generated so it opens at once. The examples are an ordered list of kinds rather than a fixed set, because maps make different things: an image (a poster), pages (a packet that shows its first page and expands to every page), a deck (cards that expand from a stack to a grid) and a table (a real excerpt of the spreadsheet a map exports, with translatable headers). An example whose image is missing drops out quietly: no broken-image icon, no empty frame. The pointer at the button goes for good once that map's popup has been opened and closed; a checkbox in the popup turns it off on every map. Both are kept in localStorage behind a guard, so private browsing cannot break the page. On a phone the popup takes the whole screen; Escape closes it and focus returns to the button. The pointer's motion stops under prefers-reduced-motion. All of its text is one new catalogue area, 'mapInfo', in all eleven locales: the component's own words, and each map's lead, takeaway and note as mapInfo.tab.<tabId>.<field>. One area because a bundle ships only the areas its source names, and the gallery that mounts a popup per map names none of the maps' own areas. This commit carries the UPG-100 map's two sentences; the other maps' follow.
This commit is contained in:
parent
a2809b31fc
commit
93f809ebcd
13 changed files with 627 additions and 0 deletions
|
|
@ -42,6 +42,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/dashboards/DashboardFilterChips.vue'`
|
||||
- **PinHint** — PinHint.vue — the conversion coach-mark (bottom-center of any map).
|
||||
`import … from '@map/components/hints/PinHint.vue'`
|
||||
- **MapInfo** — MapInfo.vue — a map's "what can this do for you?" button, one-time pointer and example popup.
|
||||
`import … from '@map/components/info/MapInfo.vue'`
|
||||
- **ApproxPinNote** — ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
|
||||
`import … from '@map/components/legends/ApproxPinNote.vue'`
|
||||
- **LegendCollapseCaret** — LegendCollapseCaret.vue — SHARED (@map/components/legends/LegendCollapseCaret.vue).
|
||||
|
|
|
|||
350
library/components/info/MapInfo.vue
Normal file
350
library/components/info/MapInfo.vue
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<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()
|
||||
}
|
||||
function onKey(e) { if (e.key === 'Escape') { e.stopPropagation(); hide() } }
|
||||
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; }
|
||||
.mi-at-right{ right:var(--map-info-side, 12px); }
|
||||
.mi-at-left{ left: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{ right:calc(var(--map-info-side, 12px) + 50px); }
|
||||
.mi-pointer.mi-at-left{ left:calc(var(--map-info-side, 12px) + 50px); flex-direction:row-reverse;
|
||||
animation-name:mi-nudge-left; }
|
||||
.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; }
|
||||
.mi-stack{ position:relative; }
|
||||
.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. */
|
||||
@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 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>
|
||||
25
library/i18n/locales/ar/mapInfo.json
Normal file
25
library/i18n/locales/ar/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "ماذا يمكن أن تقدّم لك هذه الخريطة؟",
|
||||
"title": "ماذا يمكن أن تقدّم لك هذه الخريطة؟",
|
||||
"pointer": "ماذا تفعل هذه الخريطة؟",
|
||||
"close": "إغلاق",
|
||||
"takeaway": "ما تحصل عليه:",
|
||||
"poster": "ملصق",
|
||||
"packet": "حزمة الصلاة",
|
||||
"cards": "بطاقات الصلاة",
|
||||
"spreadsheet": "جدول بيانات",
|
||||
"example": "مثال",
|
||||
"expandPages": "عرض كل الصفحات ({n})",
|
||||
"collapsePages": "عرض الصفحة الأولى فقط",
|
||||
"expandDeck": "عرض كل البطاقات ({n})",
|
||||
"collapseDeck": "تجميع البطاقات",
|
||||
"moreRows": "صفوف إضافية في جدول البيانات الكامل: {n}",
|
||||
"openFull": "فتح بالحجم الكامل",
|
||||
"pointerOff": "لا تُشِر إلى هذا الزر مرة أخرى",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "قائمة UPG-100 على خريطة واحدة: الشعوب غير المُبشَّرة، مجمّعة في مناطق يمكنك أن تتبنّاها بالصلاة.",
|
||||
"takeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/de/mapInfo.json
Normal file
25
library/i18n/locales/de/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "Was kann diese Karte für Sie tun?",
|
||||
"title": "Was kann diese Karte für Sie tun?",
|
||||
"pointer": "Was kann diese Karte?",
|
||||
"close": "Schließen",
|
||||
"takeaway": "Was Sie mitnehmen:",
|
||||
"poster": "Poster",
|
||||
"packet": "Gebetsheft",
|
||||
"cards": "Gebetskarten",
|
||||
"spreadsheet": "Tabelle",
|
||||
"example": "Beispiel",
|
||||
"expandPages": "Alle Seiten anzeigen ({n})",
|
||||
"collapsePages": "Nur die erste Seite anzeigen",
|
||||
"expandDeck": "Alle Karten anzeigen ({n})",
|
||||
"collapseDeck": "Karten stapeln",
|
||||
"moreRows": "Weitere Zeilen in der vollständigen Tabelle: {n}",
|
||||
"openFull": "In voller Größe öffnen",
|
||||
"pointerOff": "Nicht mehr auf diese Schaltfläche hinweisen",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "Die UPG-100-Liste auf einer Karte: unerreichte Volksgruppen, zusammengefasst in Zonen, die Sie im Gebet übernehmen können.",
|
||||
"takeaway": "für jede Zone ein Wandposter, ein Gebetsheft zum Weitergeben und ein Satz Gebetskarten. Das Beispiel unten ist die Zone mit den meisten Menschen in Volksgruppen, unter denen noch niemand Gemeinden gründet."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/en/mapInfo.json
Normal file
25
library/i18n/locales/en/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "What can this map do for you?",
|
||||
"title": "What can this map do for you?",
|
||||
"pointer": "What can this map do?",
|
||||
"close": "Close",
|
||||
"takeaway": "What you take away:",
|
||||
"poster": "Poster",
|
||||
"packet": "Prayer packet",
|
||||
"cards": "Prayer cards",
|
||||
"spreadsheet": "Spreadsheet",
|
||||
"example": "Example",
|
||||
"expandPages": "Show all pages ({n})",
|
||||
"collapsePages": "Show the first page only",
|
||||
"expandDeck": "Show all cards ({n})",
|
||||
"collapseDeck": "Stack the cards",
|
||||
"moreRows": "More rows in the full spreadsheet: {n}",
|
||||
"openFull": "Open full size",
|
||||
"pointerOff": "Don't point at this button again",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "The UPG-100 list on one map: unreached people groups, gathered into zones you can adopt in prayer.",
|
||||
"takeaway": "for any zone, a wall poster, a prayer packet to hand out and a deck of prayer cards. The example below is the zone with the most people in groups where no one is planting churches yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/es/mapInfo.json
Normal file
25
library/i18n/locales/es/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "¿Qué puede hacer este mapa por usted?",
|
||||
"title": "¿Qué puede hacer este mapa por usted?",
|
||||
"pointer": "¿Qué hace este mapa?",
|
||||
"close": "Cerrar",
|
||||
"takeaway": "Lo que se lleva:",
|
||||
"poster": "Póster",
|
||||
"packet": "Paquete de oración",
|
||||
"cards": "Tarjetas de oración",
|
||||
"spreadsheet": "Hoja de cálculo",
|
||||
"example": "Ejemplo",
|
||||
"expandPages": "Ver todas las páginas ({n})",
|
||||
"collapsePages": "Ver solo la primera página",
|
||||
"expandDeck": "Ver todas las tarjetas ({n})",
|
||||
"collapseDeck": "Apilar las tarjetas",
|
||||
"moreRows": "Más filas en la hoja de cálculo completa: {n}",
|
||||
"openFull": "Abrir a tamaño completo",
|
||||
"pointerOff": "No volver a señalar este botón",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "La lista UPG-100 en un solo mapa: pueblos no alcanzados, agrupados en zonas que usted puede adoptar en oración.",
|
||||
"takeaway": "para cada zona, un póster para la pared, un paquete de oración para repartir y un mazo de tarjetas de oración. El ejemplo de abajo es la zona con más personas en pueblos donde todavía nadie está plantando iglesias."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/fr/mapInfo.json
Normal file
25
library/i18n/locales/fr/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "Que peut faire cette carte pour vous ?",
|
||||
"title": "Que peut faire cette carte pour vous ?",
|
||||
"pointer": "Que fait cette carte ?",
|
||||
"close": "Fermer",
|
||||
"takeaway": "Ce que vous emportez :",
|
||||
"poster": "Affiche",
|
||||
"packet": "Livret de prière",
|
||||
"cards": "Cartes de prière",
|
||||
"spreadsheet": "Tableur",
|
||||
"example": "Exemple",
|
||||
"expandPages": "Afficher toutes les pages ({n})",
|
||||
"collapsePages": "N'afficher que la première page",
|
||||
"expandDeck": "Afficher toutes les cartes ({n})",
|
||||
"collapseDeck": "Empiler les cartes",
|
||||
"moreRows": "Lignes supplémentaires dans le tableur complet : {n}",
|
||||
"openFull": "Ouvrir en taille réelle",
|
||||
"pointerOff": "Ne plus signaler ce bouton",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "La liste UPG-100 sur une seule carte : les peuples non atteints, regroupés en zones que vous pouvez adopter dans la prière.",
|
||||
"takeaway": "pour chaque zone, une affiche murale, un livret de prière à distribuer et un jeu de cartes de prière. L'exemple ci-dessous est la zone qui compte le plus de personnes dans des peuples où personne n'implante encore d'Églises."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/hi/mapInfo.json
Normal file
25
library/i18n/locales/hi/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "यह नक्शा आपके लिए क्या कर सकता है?",
|
||||
"title": "यह नक्शा आपके लिए क्या कर सकता है?",
|
||||
"pointer": "यह नक्शा क्या करता है?",
|
||||
"close": "बंद करें",
|
||||
"takeaway": "आपको क्या मिलता है:",
|
||||
"poster": "पोस्टर",
|
||||
"packet": "प्रार्थना पुस्तिका",
|
||||
"cards": "प्रार्थना कार्ड",
|
||||
"spreadsheet": "स्प्रेडशीट",
|
||||
"example": "उदाहरण",
|
||||
"expandPages": "सभी पृष्ठ दिखाएँ ({n})",
|
||||
"collapsePages": "केवल पहला पृष्ठ दिखाएँ",
|
||||
"expandDeck": "सभी कार्ड दिखाएँ ({n})",
|
||||
"collapseDeck": "कार्ड एक साथ रखें",
|
||||
"moreRows": "पूरी स्प्रेडशीट में और पंक्तियाँ: {n}",
|
||||
"openFull": "पूरे आकार में खोलें",
|
||||
"pointerOff": "इस बटन की ओर फिर से इशारा न करें",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "UPG-100 सूची एक ही नक्शे पर: अपहुँचे जन-समूह, क्षेत्रों में बाँटे गए, जिन्हें आप प्रार्थना में अपना सकते हैं।",
|
||||
"takeaway": "हर क्षेत्र के लिए दीवार का एक पोस्टर, बाँटने के लिए एक प्रार्थना पुस्तिका और प्रार्थना कार्डों का एक सेट। नीचे का उदाहरण वह क्षेत्र है जहाँ ऐसे जन-समूहों में सबसे अधिक लोग हैं जिनके बीच अभी कोई कलीसिया स्थापित नहीं कर रहा है।"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/it/mapInfo.json
Normal file
25
library/i18n/locales/it/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "Che cosa può fare questa mappa per te?",
|
||||
"title": "Che cosa può fare questa mappa per te?",
|
||||
"pointer": "Che cosa fa questa mappa?",
|
||||
"close": "Chiudi",
|
||||
"takeaway": "Che cosa ottieni:",
|
||||
"poster": "Poster",
|
||||
"packet": "Libretto di preghiera",
|
||||
"cards": "Carte di preghiera",
|
||||
"spreadsheet": "Foglio di calcolo",
|
||||
"example": "Esempio",
|
||||
"expandPages": "Mostra tutte le pagine ({n})",
|
||||
"collapsePages": "Mostra solo la prima pagina",
|
||||
"expandDeck": "Mostra tutte le carte ({n})",
|
||||
"collapseDeck": "Impila le carte",
|
||||
"moreRows": "Altre righe nel foglio di calcolo completo: {n}",
|
||||
"openFull": "Apri a grandezza intera",
|
||||
"pointerOff": "Non indicare più questo pulsante",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "La lista UPG-100 su un'unica mappa: popoli non raggiunti, raggruppati in zone che puoi adottare nella preghiera.",
|
||||
"takeaway": "per ogni zona, un poster da parete, un libretto di preghiera da distribuire e un mazzo di carte di preghiera. L'esempio qui sotto è la zona con più persone in popoli dove nessuno sta ancora fondando chiese."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/pt/mapInfo.json
Normal file
25
library/i18n/locales/pt/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "O que este mapa pode fazer por você?",
|
||||
"title": "O que este mapa pode fazer por você?",
|
||||
"pointer": "O que este mapa faz?",
|
||||
"close": "Fechar",
|
||||
"takeaway": "O que você leva:",
|
||||
"poster": "Pôster",
|
||||
"packet": "Pacote de oração",
|
||||
"cards": "Cartões de oração",
|
||||
"spreadsheet": "Planilha",
|
||||
"example": "Exemplo",
|
||||
"expandPages": "Mostrar todas as páginas ({n})",
|
||||
"collapsePages": "Mostrar só a primeira página",
|
||||
"expandDeck": "Mostrar todos os cartões ({n})",
|
||||
"collapseDeck": "Empilhar os cartões",
|
||||
"moreRows": "Mais linhas na planilha completa: {n}",
|
||||
"openFull": "Abrir em tamanho real",
|
||||
"pointerOff": "Não apontar mais para este botão",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "A lista UPG-100 em um só mapa: povos não alcançados, agrupados em zonas que você pode adotar em oração.",
|
||||
"takeaway": "para cada zona, um pôster de parede, um pacote de oração para distribuir e um baralho de cartões de oração. O exemplo abaixo é a zona com mais pessoas em povos onde ninguém ainda está plantando igrejas."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/ro/mapInfo.json
Normal file
25
library/i18n/locales/ro/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "Ce poate face această hartă pentru tine?",
|
||||
"title": "Ce poate face această hartă pentru tine?",
|
||||
"pointer": "Ce face această hartă?",
|
||||
"close": "Închide",
|
||||
"takeaway": "Ce primești:",
|
||||
"poster": "Poster",
|
||||
"packet": "Pachet de rugăciune",
|
||||
"cards": "Cartonașe de rugăciune",
|
||||
"spreadsheet": "Foaie de calcul",
|
||||
"example": "Exemplu",
|
||||
"expandPages": "Arată toate paginile ({n})",
|
||||
"collapsePages": "Arată doar prima pagină",
|
||||
"expandDeck": "Arată toate cartonașele ({n})",
|
||||
"collapseDeck": "Strânge cartonașele",
|
||||
"moreRows": "Rânduri suplimentare în foaia de calcul completă: {n}",
|
||||
"openFull": "Deschide la dimensiune completă",
|
||||
"pointerOff": "Nu mai indica acest buton",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "Lista UPG-100 pe o singură hartă: popoare neatinse, grupate în zone pe care le poți adopta în rugăciune.",
|
||||
"takeaway": "pentru fiecare zonă, un poster de perete, un pachet de rugăciune de împărțit și un set de cartonașe de rugăciune. Exemplul de mai jos este zona cu cei mai mulți oameni din popoare în care nimeni nu plantează încă biserici."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/ru/mapInfo.json
Normal file
25
library/i18n/locales/ru/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "Чем эта карта может быть вам полезна?",
|
||||
"title": "Чем эта карта может быть вам полезна?",
|
||||
"pointer": "Что умеет эта карта?",
|
||||
"close": "Закрыть",
|
||||
"takeaway": "Что вы получаете:",
|
||||
"poster": "Плакат",
|
||||
"packet": "Молитвенный буклет",
|
||||
"cards": "Молитвенные карточки",
|
||||
"spreadsheet": "Таблица",
|
||||
"example": "Пример",
|
||||
"expandPages": "Показать все страницы ({n})",
|
||||
"collapsePages": "Показать только первую страницу",
|
||||
"expandDeck": "Показать все карточки ({n})",
|
||||
"collapseDeck": "Сложить карточки в стопку",
|
||||
"moreRows": "Ещё строк в полной таблице: {n}",
|
||||
"openFull": "Открыть в полном размере",
|
||||
"pointerOff": "Больше не указывать на эту кнопку",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "Список UPG-100 на одной карте: недостигнутые народы, объединённые в зоны, которые вы можете взять на молитвенное попечение.",
|
||||
"takeaway": "для каждой зоны — настенный плакат, молитвенный буклет для раздачи и набор молитвенных карточек. Ниже показана зона, где больше всего людей в народах, среди которых пока никто не основывает церкви."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
library/i18n/locales/zh/mapInfo.json
Normal file
25
library/i18n/locales/zh/mapInfo.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"open": "这张地图能为你做什么?",
|
||||
"title": "这张地图能为你做什么?",
|
||||
"pointer": "这张地图能做什么?",
|
||||
"close": "关闭",
|
||||
"takeaway": "你能带走的:",
|
||||
"poster": "海报",
|
||||
"packet": "祷告手册",
|
||||
"cards": "祷告卡",
|
||||
"spreadsheet": "电子表格",
|
||||
"example": "示例",
|
||||
"expandPages": "显示全部页面({n})",
|
||||
"collapsePages": "只显示第一页",
|
||||
"expandDeck": "显示全部卡片({n})",
|
||||
"collapseDeck": "把卡片叠起来",
|
||||
"moreRows": "完整电子表格中的更多行:{n}",
|
||||
"openFull": "以完整尺寸打开",
|
||||
"pointerOff": "不再指向这个按钮",
|
||||
"tab": {
|
||||
"upg-100": {
|
||||
"lead": "UPG-100 名单尽在一张地图上:未得之民按区域分组,你可以在祷告中认领一个区域。",
|
||||
"takeaway": "每个区域都有一张墙面海报、一本可分发的祷告手册和一套祷告卡。下面的示例是这样一个区域:在尚无人植堂的族群中,这里的人数最多。"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue