Research Gallery: speak the host page's language, and prove the composites' links

Language. The gallery fell back to the browser's language and forced it
onto every nested map through profile-config.locale, while its own rail
stayed English. Measured: a Spanish page viewed in a German browser showed
German maps under an English rail. It now follows the ladder every nested
map already uses: the `language` attribute, then ?lang=, then <html lang>
(watched live, so a host that switches language switches the gallery), then
English. The browser only counts once a visitor has used the gallery's own
switcher.

Its own words (rail, sections, hints, badges, notice) now ship in all 11
locales, in a private src/strings.js that reuses the library's terms.
Rail sweep, 11 locales at 1400px and 390px: 21 of 22 clean. The one clip,
Italian 'Avanzamento della preghiera' (191px in a 176px slot), now uses the
rail form 'Avanzamento preghiera'. A live <html lang> switch from en to
pt-BR re-renders the rail.

Interop (matrix cell D). check:gallery-embed gains a `linked` phase that
drives each composite's real control INSIDE a gallery tab:
- Big Picture: a facet shows "Showing 1,309" in the grid.
- Simple Research: a grid search narrows 2107 → 3. Its halves are
  independent by design.
- Regions: clicking "Africa" in the list retitles the dashboard "Africa".
- UPG-100: "Show on map" brings the map.
UPG-100's missing zone selection is an owned, printed LINKED_KNOWN entry.
Full gate: js 13, iframe 13, neighbour 13, linked 4.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 04:22:54 +00:00
parent 0907e6ade3
commit c8b13286f2
9 changed files with 349 additions and 32 deletions

View file

@ -68,6 +68,13 @@ const NEIGHBOUR_KNOWN = {
'regions-dashboard:url': 'regions owner (region-dashboards.vue URL_SYNC ignores urlSync:false)',
'regions-dashboard:title': 'regions owner (region-dashboards.vue syncTitle)',
}
// KNOWN, ROUTED gaps in a composite's internal link (not measurable as pass/fail yet: no DOM hook).
const LINKED_KNOWN = {
// upg-100-mfe.vue:143-148 answers `doxa-upg-100-list:show-on-map` with go('map') only; it never
// re-dispatches `doxa:select-zone` (the event upg-100-list.vue:1366-1381 isolates a zone on), so
// "Show on map" scrolls to the map without selecting that list's zone. Routed 2026-09-25.
'upg-100: "Show on map" does not select the zone on the map': 'upg100-plugin (upg-100-mfe.vue:143 re-dispatch doxa:select-zone)',
}
const MAPBOX_KNOWN = {
// EMPTY ON PURPOSE. The one entry here — my-upg-100-list/upg-100-list.vue:1464 pinning
// mapbox-gl.css v3.3.0 regardless of engine — was fixed and the exemption deleted in the SAME
@ -243,6 +250,71 @@ try {
}
checked.neighbour = ids.length
await page.close()
// ── LINKED PHASE — matrix cell D for the composites: "a filter in one part updates the other",
// driven INSIDE a gallery tab (the composites' own leaf pages are covered by verify:interop; this
// proves the link survives being nested one level deeper, in the gallery's document). Each check
// clicks the composite's REAL control and polls an observable in the OTHER part. Playwright CSS
// pierces open shadow roots, so one locator reaches through the gallery and the composite.
// Mechanics (found 2026-09-25, file:line in each owner's plugin):
// Big Picture dashboard facet → `dashboard-filter` emit → grid props; the grid's count
// line (.repel) is ABSENT until a filter applies, then reads "Showing N".
// Simple Research the two halves are INDEPENDENT BY DESIGN (research-map-plus-grid.vue:49-56),
// so D asserts each half narrows on its own control — the grid's search.
// Regions list `region-selected` → window `doxa-regions-dashboard:select` → the
// dashboard's h1.rd-title becomes the region.
// UPG-100 "Show on map" must bring the map; selecting the ZONE on it is a routed gap.
mode = 'js'
const lp = await browser.newPage({ viewport: { width: 1400, height: 900 } })
await lp.goto(`http://localhost:${HOST_PORT}/`)
const lbooted = await lp.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false)
const pane = () => lp.locator('doxa-maps-spa section.dms-pane:not([hidden])')
const open = async (id) => {
await lp.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
await lp.waitForFunction(id => { const vm = document.querySelector('doxa-maps-spa')._instance.proxy; const t = vm.tabs.find(x => x.id === id); return t && t.state === 'ready' }, id, { timeout: 60000 })
await lp.waitForTimeout(6000) // let the composite fetch + paint its data before touching it
}
const poll = async (fn, ms = 25000) => { const end = Date.now() + ms; let v; while (Date.now() < end) { v = await fn().catch(() => null); if (v && v.ok) return v; await lp.waitForTimeout(700) } return v || { ok: false, why: 'no observation' } }
const LINKS = {
'dashboard-plus-grid': async () => {
const count = pane().locator('.repel').filter({ hasText: /Showing/ })
const before = await count.count()
const facet = pane().locator('[data-facet="wagf_region"][data-value]:not(.dash-only)').first()
const val = await facet.getAttribute('data-value', { timeout: 20000 })
await facet.click()
return poll(async () => { const n = await count.count(); const txt = n ? await count.first().innerText() : ''; return { ok: before === 0 && /Showing\s+[\d,]+/.test(txt), why: `facet wagf_region=${val} → grid count line ${n ? '"' + txt.trim().slice(0, 40) + '"' : 'still absent'}` } })
},
'research-map-plus-grid': async () => {
const total = pane().locator('.repel').first()
const num = async () => Number(((await total.innerText({ timeout: 20000 })).match(/[\d,]+/) || ['0'])[0].replace(/,/g, ''))
const before = await num()
await pane().locator('.pgg-search input').first().fill('Kurd')
return poll(async () => { const after = await num(); return { ok: before > 0 && after > 0 && after < before, why: `grid search "Kurd": ${before} → ${after}` } })
},
'regions-dashboard': async () => {
const btn = pane().locator('button.region-card__link').first()
const name = (await btn.innerText({ timeout: 20000 })).trim()
const title = pane().locator('h1.rd-title').first()
const before = (await title.innerText({ timeout: 20000 }).catch(() => '')).trim()
await btn.click()
return poll(async () => { const t = (await title.innerText()).trim(); return { ok: t !== before && t.includes(name), why: `list "${name}" → dashboard title "${before}" → "${t}"` } })
},
'upg-100': async () => {
const tape = pane().locator('.card[data-list-id] button.tape.tape-h').first()
await tape.click({ timeout: 20000 })
return poll(async () => { const on = pane().locator('.viewsel button[aria-pressed="true"]'); const txt = (await on.first().innerText()).trim(); return { ok: /map/i.test(txt) || /карт|mapa|carte|地图|خريطة|मानचित्र|karte|mappa|hartă/i.test(txt), why: `"Show on map" → active view "${txt}"` } })
}
}
if (!lbooted) fails.push('[linked] the gallery did not boot within 60s')
for (const [id, run] of Object.entries(lbooted ? LINKS : {})) {
let r
try { await open(id); r = await run() } catch (e) { r = { ok: false, why: 'threw: ' + String(e.message || e).split('\n')[0].slice(0, 160) } }
console.log(`${r.ok ? 'PASS' : 'FAIL'} [linked] ${id}: ${r.why}`)
if (!r.ok) fails.push(`[linked] ${id}: ${r.why}`)
}
for (const [k, owner] of Object.entries(LINKED_KNOWN)) console.log(`KNOWN [linked] ${k} — owner: ${owner}`)
checked.linked = Object.keys(LINKS).length
await lp.close()
}
} finally { await browser.close(); cleanup() }
@ -250,7 +322,7 @@ try {
// this entry in an inline # comment, so an appended --mode landed inside the comment) must not
// read as "both modes green": each requested mode has to have driven at least one tab.
for (const m of MODES) if (!checked[m]) fails.push(`[${m}] checked 0 tabs — this mode did not run`)
const tally = [...MODES, ...(checked.neighbour ? ['neighbour'] : [])].map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
const tally = [...MODES, ...(checked.neighbour ? ['neighbour'] : []), ...(checked.linked ? ['linked'] : [])].map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
if (fails.length) {
console.log(`\nchecked: ${tally}`)
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)

View file

@ -37,7 +37,7 @@ Add `?tab=<id>` to the `src` to open a specific map.
| Attribute | Default | What it does |
|---|---|---|
| `tab` | device default (Big Picture on desktop, Simple Research on phones) | Tab id to open first. Changing it later switches tabs. `?tab=` on the page URL works too. |
| `language` | parent page / saved choice | UI language pushed to every tab (`en`, `es`, `fr`, …). |
| `language` | your page's language | UI language for the gallery and every tab. Without it the gallery follows your page: `?lang=` in the URL, then `<html lang>` (watched live, so a page that switches language switches the gallery), then English. The visitor's browser language is never used on its own. 11 languages ship: `ar de en es fr hi it pt ro ru zh`. |
| `api-base-url` | manifest `runtime.apiBaseUrl` | Data API. Only applied when the page has not already set `window.MAP_APP_API_URL`. |
| `provider` | each map's own default (Mapbox) | Map engine for every nested map: `maplibre` needs no token and makes no Mapbox requests; `mapbox`. Forwarded to each map and, when the page has not set `window.MAP_PROVIDER`, set there too so maps nested inside composites follow. In the iframe embed use `?provider=maplibre`. |
| `tk` | manifest `runtime.token` | Mapbox public token. Only fills `window.MAP_TOKEN` when the page has not set one. |

View file

@ -58,7 +58,7 @@
driven by activeIndex. -->
<VerticalTabsLayout
:title="heading"
nav-label="Research maps"
:nav-label="t('navLabel')"
:header="showHeader"
:groups="navGroups"
:active="activeKey"
@ -77,7 +77,7 @@
</span>
</template>
<template #meta>{{ tabs.length }} maps</template>
<template #meta>{{ t('meta', { n: tabs.length }) }}</template>
<!-- #toolbar: the i18n locale switcher (3-dots/flag). GATED by showLocaleSwitcher — the cap-5
DX-Maps SPA has NO standalone language dropdown by default; it INHERITS its language from the
@ -127,18 +127,18 @@
<template v-if="!t.heavy || i === activeIndex">
<div class="dms-slot" :ref="el => setSlot(i, el)"></div>
<div v-if="t.state === 'loading'" class="dms-hint">Loading {{ t.label }}…</div>
<div v-if="t.state === 'loading'" class="dms-hint">{{ tr('loading', { label: tabLabel(t) }) }}</div>
<div v-else-if="t.state === 'error'" class="dms-hint err">
Could not load {{ t.label }}.<br><small>{{ t.error }}</small>
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br><small>{{ t.error }}</small>
</div>
<!-- ★ THE FOURTH STATE, and the one that used to render NOTHING. A tab whose scriptUrl the
manifest never supplied sits in state 'idle' forever: not loading, not errored, just an
empty slot — the grey pane the Driver reported four times. Covering it here means a
missing manifest now READS as a failure instead of looking like a blank app. -->
<div v-else-if="t.state === 'idle' && !t.scriptUrl && manifestError" class="dms-hint err">
Could not load {{ t.label }}.<br>
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br>
<small>{{ manifestError }}</small><br>
<small>This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.</small>
<small>{{ tr('manifestHint') }}</small>
</div>
</template>
</section>
@ -155,6 +155,25 @@
import { defineComponent, inject } from 'vue'
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
import { translate, LOCALES } from './src/strings.js'
// THE HOST PAGE'S LANGUAGE, by the same ladder every nested map already uses (library/i18n
// detectLocale): ?lang= / ?locale= on the page URL, then <html lang>. '' when the page says nothing.
// NOT navigator.language: the gallery used to fall back to the BROWSER's language and force it onto
// every child through profile-config.locale — measured 2026-09-25, a Spanish page (<html lang="es">)
// viewed in a German browser rendered its maps in German and its own rail in English.
function detectHostLocale() {
try {
const q = new URLSearchParams(location.search)
const fromUrl = (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromUrl)) return fromUrl
} catch { /* no window */ }
try {
const fromHtml = (document.documentElement.lang || '').split('-')[0].toLowerCase()
if (LOCALES.includes(fromHtml)) return fromHtml
} catch { /* no document */ }
return ''
}
// The Driver's section/folder nav model, ported from app-wrapper-material/nav.js (the verified
// source of the exact sections the Driver wants). Each entry is a GROUP for VerticalTabsLayout; each
@ -180,7 +199,7 @@ const NAV_MODEL = [
// dev-time gate below); the fix is that Dashboards now owns TWO DISTINCT rows of its own — Big
// Picture never appears in Prayer Mobilization again — so it is not a one-row section that begs
// to be folded away.
{ key: 'dashboards', label: 'Dashboards', icon: 'chart',
{ key: 'dashboards', label: 'Dashboards', i18n: 'sec.dashboards', icon: 'chart',
tabs: [
// 1. Big Picture — desktop default. Present on EVERY width (the device rule is "same tab
// order everywhere"), but DISABLED on mobile rather than hidden — see isBigPictureDisabled /
@ -215,7 +234,7 @@ const NAV_MODEL = [
// "if you're trying to mobilize prayer, UPG-100 list probably comes first because it's a NEW thing
// to people — you want them introduced to it first" — hand the newcomer the concrete new artifact,
// THEN ask them to pray it.
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart',
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', i18n: 'sec.prayer', icon: 'heart',
tabs: [
{ id: 'upg-100', icon: 'list' }, // 1. UPG-100 List — the NEW thing, introduced first
{ id: 'prayer-map', icon: 'embed' }, // 2. Prayer Progress — now pray the list
@ -226,7 +245,7 @@ const NAV_MODEL = [
// 2 (Simple Research) — so the standalone map is left OUT of every curated section and falls
// through to Experimental automatically (see curatedBundles/experimentalTabsFromManifest); that is
// correct, not an omission.
{ key: 'gospel-research', label: 'Gospel Research', icon: 'search',
{ key: 'gospel-research', label: 'Gospel Research', i18n: 'sec.gospel', icon: 'search',
tabs: [
{ id: 'cities-internet', icon: 'wifi' }, // renamed label: "Cities with Internet"
{ id: 'gospel-resources', icon: 'book' },
@ -243,7 +262,7 @@ const NAV_MODEL = [
// Driver set for the data bundler: handle every plugin generically, or the next author gets
// nothing. The named sections above stay curated (they encode the teaching order); Experimental
// is the automatic catch-all so nothing can go missing.
{ key: 'experimental', label: 'Experimental', icon: 'flask', folder: true, open: false, tabs: [] },
{ key: 'experimental', label: 'Experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: false, tabs: [] },
]
// Bundles that are NOT candidates for auto-discovery into Experimental, with the reason each is
@ -786,6 +805,9 @@ export default defineComponent({
runtime: {},
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
locale: loadLocale(),
// The host page's own language (detectHostLocale), kept live by a MutationObserver on <html lang>
// — a host app that switches language at runtime (Nuxt i18n does) re-drives the whole gallery.
hostLang: detectHostLocale(),
localeOpen: false,
switcherLangs: SWITCHER_LANGUAGES,
// Curated tabs start from TAB_DEFS and are COMPLETED from the manifest (tag + scriptUrl) as
@ -872,8 +894,8 @@ export default defineComponent({
// of a mount.
const disabled = this.isBigPictureDisabled(t.id)
return {
key: t.id, label: t.label, sub: t.sub, icon: r.icon,
badge: disabled ? 'desktop only' : r.badge, disabled
key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: r.icon,
badge: disabled ? this.tr('desktopOnly') : r.badge, disabled
}
}),
seen)
@ -886,10 +908,10 @@ export default defineComponent({
// routing it through `seen` means even a mis-declared bundle can only ever paint one row.
rows = rows.concat(dedupeRows(
this.tabs.filter(t => t.discovered)
.map(t => ({ key: t.id, label: t.label, sub: t.sub, icon: 'flask', badge: 'lab' })),
.map(t => ({ key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: 'flask', badge: this.tr('lab') })),
seen))
}
return { key: g.key, label: g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
return { key: g.key, label: g.i18n ? this.tr(g.i18n) : g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
})
.filter(g => g.rows.length)
},
@ -904,10 +926,17 @@ export default defineComponent({
// 1. `language` prop / profile-config.language|locale|lang — the parent-page (inherited) value.
// 2. the manual switcher's data.locale (only meaningful when a header dropdown is present).
// Every tab's element receives THIS via profile-config.locale, so the SPA honors one language prop.
// 1. `language` prop / profile-config.language|locale|lang — what the host SAYS.
// 2. the gallery's own switcher choice — only while that switcher is actually offered.
// 3. the host page's language (?lang= / <html lang>, see detectHostLocale).
// 4. English.
effectiveLocale() {
const fromProp = (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
return (fromProp || this.locale || '').split('-')[0].toLowerCase()
const fromSwitcher = this.showLocaleSwitcher ? this.locale : ''
return (fromProp || fromSwitcher || this.hostLang || 'en').split('-')[0].toLowerCase()
},
// The locale the gallery's OWN words render in: the effective one when it ships, else English.
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
// The engine a HOST asked for ('' = none; each map keeps its own ladder).
effectiveProvider() {
const p = String(this.provider || this.cfg.provider || '').trim().toLowerCase()
@ -924,6 +953,7 @@ export default defineComponent({
// then drop the row out from under the active highlight — the tab would be mounted but
// unreachable, which reads as a broken app rather than a flicker.
this.watchWidth()
this.watchHostLang()
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
this.select(this.initialIndex())
@ -937,6 +967,7 @@ export default defineComponent({
// will (a cap-5 SPA is mounted and unmounted by shells, tabs and the studio), and a live listener
// holding a reference to a detached component leaks it for the page's lifetime.
beforeUnmount() {
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
if (!this._mql || !this._onMedia) return
if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMedia)
else if (this._mql.removeListener) this._mql.removeListener(this._onMedia)
@ -964,6 +995,18 @@ export default defineComponent({
},
methods: {
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
// The gallery's own words in uiLocale (src/strings.js). `t` in the template is shadowed by the
// v-for tab variable inside the panes, so those use `tr`; both are the same function.
tr(key, params) { return translate(this.uiLocale, key, params) },
t(key, params) { return translate(this.uiLocale, key, params) },
// A curated tab's label/sub come from the string table; a DISCOVERED (Experimental) tab has only
// its manifest title, which stays as published. Its English manifest description is replaced by
// the translated generic subtitle outside English, so no English prose leaks into the rail.
tabLabel(t) { return t.discovered ? t.label : translate(this.uiLocale, 'tab.' + t.id) },
tabSub(t) {
if (!t.discovered) return translate(this.uiLocale, 'sub.' + t.id)
return this.uiLocale === 'en' ? t.sub : this.tr('experimentalSub')
},
// ── VIEWPORT WATCH (the device rule: same rail, different default + one disabled row) ────────
// Mirrors the SAME (max-width:760px) query VerticalTabsLayout uses. See the `narrow` note in
// data() for why the shell must watch it independently.
@ -971,6 +1014,13 @@ export default defineComponent({
// FAIL-SOFT: if matchMedia is unavailable (an old webview, a non-browser render) `narrow` stays
// false and the SPA behaves exactly as it did before this change — desktop rail, Big Picture
// first. A viewport feature must never be able to take the app down.
// Follow the host page's <html lang> live. Fail-soft: without MutationObserver the language read
// at mount stands.
watchHostLang() {
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
this._langObs = new MutationObserver(() => { this.hostLang = detectHostLocale() })
this._langObs.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
},
watchWidth() {
if (typeof window === 'undefined' || !window.matchMedia) return
this._mql = window.matchMedia('(max-width: 760px)')
@ -1151,7 +1201,7 @@ export default defineComponent({
// hide."
onSelect(key) {
if (this.isBigPictureDisabled(key)) {
this.disabledNotice = 'Big Picture only supports desktop — open this page on a desktop to use it.'
this.disabledNotice = this.tr('desktopNotice')
clearTimeout(this._noticeTimer)
this._noticeTimer = setTimeout(() => { this.disabledNotice = '' }, 4000)
return

View file

@ -0,0 +1,195 @@
/**
* strings.js — the Research Gallery's own words, in the 11 shipped locales.
*
* PRIVATE TO THIS PLUGIN, on purpose. These ~25 strings belong to the gallery shell alone (its rail,
* sections and status hints), so they live in this folder rather than in library/i18n, whose engine
* inlines every namespace into every bundle. Each nested map translates ITSELF; this only covers the
* chrome around them.
*
* VOCABULARY FOLLOWS THE LIBRARY. Where a term already has a shipped translation it is reused verbatim,
* so the rail row and the map it opens use the same words: legend.title.prayer (Prayer Progress),
* legend.title.gospelResources (Gospel Resources), tabs.regions / tabs.countries, tabs.upg100Zone
* (UPG-100), and tabs.group for "people group" (Pueblo, Peuple, Volksgruppe, …).
*
* `en` is the source and the fallback: a key missing in a locale renders in English rather than as a
* key name. {n} and {label} are the only placeholders.
*/
export const STRINGS = {
en: {
'sec.dashboards': 'Dashboards', 'sec.prayer': 'Prayer Mobilization', 'sec.gospel': 'Gospel Research', 'sec.experimental': 'Experimental',
'tab.dashboard-plus-grid': 'Big Picture', 'sub.dashboard-plus-grid': 'dashboard + people-groups grid, stacked',
'tab.research-map': 'Research Map', 'sub.research-map': 'prayer · engagement · regions',
'tab.research-map-plus-grid': 'Simple Research', 'sub.research-map-plus-grid': 'research map + people-groups grid',
'tab.prayer-map': 'Prayer Progress', 'sub.prayer-map': 'simple world prayer map',
'tab.cities-internet': 'Cities with Internet', 'sub.cities-internet': 'connectivity of key cities',
'tab.gospel-resources': 'Gospel Resources', 'sub.gospel-resources': 'resources by place',
'tab.regions-dashboard': 'Regions', 'sub.regions-dashboard': 'regions and their countries',
'tab.upg-100': 'UPG-100 List', 'sub.upg-100': 'my unreached list + posters',
navLabel: 'Research maps', meta: '{n} maps', loading: 'Loading {label}…', loadFailed: 'Could not load {label}.',
manifestHint: 'This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.',
desktopOnly: 'desktop only', lab: 'lab', experimentalSub: 'experimental bundle',
desktopNotice: 'Big Picture only supports desktop — open this page on a desktop to use it.'
},
es: {
'sec.dashboards': 'Paneles', 'sec.prayer': 'Movilización de oración', 'sec.gospel': 'Investigación del Evangelio', 'sec.experimental': 'Experimental',
'tab.dashboard-plus-grid': 'Panorama general', 'sub.dashboard-plus-grid': 'panel + cuadrícula de pueblos',
'tab.research-map': 'Mapa de investigación', 'sub.research-map': 'oración · compromiso · regiones',
'tab.research-map-plus-grid': 'Investigación simple', 'sub.research-map-plus-grid': 'mapa de investigación + cuadrícula de pueblos',
'tab.prayer-map': 'Progreso de oración', 'sub.prayer-map': 'mapa mundial de oración',
'tab.cities-internet': 'Ciudades con Internet', 'sub.cities-internet': 'conectividad de ciudades clave',
'tab.gospel-resources': 'Recursos del Evangelio', 'sub.gospel-resources': 'recursos por lugar',
'tab.regions-dashboard': 'Regiones', 'sub.regions-dashboard': 'regiones y sus países',
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'mi lista de no alcanzados + carteles',
navLabel: 'Mapas de investigación', meta: '{n} mapas', loading: 'Cargando {label}…', loadFailed: 'No se pudo cargar {label}.',
manifestHint: 'Esta pestaña se compone a partir de manifest.json: compila el árbol o sirve el árbol compilado para cargarla.',
desktopOnly: 'solo escritorio', lab: 'beta', experimentalSub: 'mapa experimental',
desktopNotice: 'Panorama general solo funciona en escritorio: abre esta página en una computadora para usarlo.'
},
fr: {
'sec.dashboards': 'Tableaux de bord', 'sec.prayer': 'Mobilisation à la prière', 'sec.gospel': "Recherche sur l'Évangile", 'sec.experimental': 'Expérimental',
'tab.dashboard-plus-grid': "Vue d'ensemble", 'sub.dashboard-plus-grid': 'tableau de bord + grille des peuples',
'tab.research-map': 'Carte de recherche', 'sub.research-map': 'prière · engagement · régions',
'tab.research-map-plus-grid': 'Recherche simple', 'sub.research-map-plus-grid': 'carte de recherche + grille des peuples',
'tab.prayer-map': 'Progression de la prière', 'sub.prayer-map': 'carte mondiale de la prière',
'tab.cities-internet': 'Villes avec Internet', 'sub.cities-internet': 'connectivité des villes clés',
'tab.gospel-resources': "Ressources de l'Évangile", 'sub.gospel-resources': 'ressources par lieu',
'tab.regions-dashboard': 'Régions', 'sub.regions-dashboard': 'régions et leurs pays',
'tab.upg-100': 'Liste UPG-100', 'sub.upg-100': 'ma liste des non atteints + affiches',
navLabel: 'Cartes de recherche', meta: '{n} cartes', loading: 'Chargement de {label}…', loadFailed: 'Impossible de charger {label}.',
manifestHint: "Cet onglet est composé à partir de manifest.json : construisez l'arborescence ou servez l'arborescence construite pour le charger.",
desktopOnly: 'ordinateur uniquement', lab: 'bêta', experimentalSub: 'carte expérimentale',
desktopNotice: "Vue d'ensemble ne fonctionne que sur ordinateur : ouvrez cette page sur un ordinateur pour l'utiliser."
},
de: {
'sec.dashboards': 'Dashboards', 'sec.prayer': 'Gebetsmobilisierung', 'sec.gospel': 'Evangeliumsforschung', 'sec.experimental': 'Experimentell',
'tab.dashboard-plus-grid': 'Gesamtbild', 'sub.dashboard-plus-grid': 'Dashboard + Raster der Volksgruppen',
'tab.research-map': 'Forschungskarte', 'sub.research-map': 'Gebet · Engagement · Regionen',
'tab.research-map-plus-grid': 'Einfache Recherche', 'sub.research-map-plus-grid': 'Forschungskarte + Raster der Volksgruppen',
'tab.prayer-map': 'Gebetsfortschritt', 'sub.prayer-map': 'einfache Weltkarte des Gebets',
'tab.cities-internet': 'Städte mit Internet', 'sub.cities-internet': 'Konnektivität wichtiger Städte',
'tab.gospel-resources': 'Evangeliumsressourcen', 'sub.gospel-resources': 'Ressourcen nach Ort',
'tab.regions-dashboard': 'Regionen', 'sub.regions-dashboard': 'Regionen und ihre Länder',
'tab.upg-100': 'UPG-100-Liste', 'sub.upg-100': 'meine Liste der Unerreichten + Poster',
navLabel: 'Forschungskarten', meta: '{n} Karten', loading: '{label} wird geladen…', loadFailed: '{label} konnte nicht geladen werden.',
manifestHint: 'Dieser Tab wird aus manifest.json zusammengesetzt – bauen Sie den Baum oder stellen Sie den gebauten Baum bereit, um ihn zu laden.',
desktopOnly: 'nur Desktop', lab: 'Beta', experimentalSub: 'experimentelle Karte',
desktopNotice: 'Gesamtbild funktioniert nur am Desktop – öffnen Sie diese Seite an einem Computer, um es zu nutzen.'
},
pt: {
'sec.dashboards': 'Painéis', 'sec.prayer': 'Mobilização de oração', 'sec.gospel': 'Pesquisa do Evangelho', 'sec.experimental': 'Experimental',
'tab.dashboard-plus-grid': 'Visão geral', 'sub.dashboard-plus-grid': 'painel + grade de povos',
'tab.research-map': 'Mapa de pesquisa', 'sub.research-map': 'oração · engajamento · regiões',
'tab.research-map-plus-grid': 'Pesquisa simples', 'sub.research-map-plus-grid': 'mapa de pesquisa + grade de povos',
'tab.prayer-map': 'Progresso da oração', 'sub.prayer-map': 'mapa mundial de oração',
'tab.cities-internet': 'Cidades com Internet', 'sub.cities-internet': 'conectividade das principais cidades',
'tab.gospel-resources': 'Recursos do Evangelho', 'sub.gospel-resources': 'recursos por lugar',
'tab.regions-dashboard': 'Regiões', 'sub.regions-dashboard': 'regiões e seus países',
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'minha lista de não alcançados + cartazes',
navLabel: 'Mapas de pesquisa', meta: '{n} mapas', loading: 'Carregando {label}…', loadFailed: 'Não foi possível carregar {label}.',
manifestHint: 'Esta aba é composta a partir do manifest.json — compile a árvore ou sirva a árvore compilada para carregá-la.',
desktopOnly: 'só no computador', lab: 'beta', experimentalSub: 'mapa experimental',
desktopNotice: 'Visão geral só funciona no computador — abra esta página em um computador para usá-la.'
},
it: {
'sec.dashboards': 'Dashboard', 'sec.prayer': 'Mobilitazione alla preghiera', 'sec.gospel': 'Ricerca sul Vangelo', 'sec.experimental': 'Sperimentale',
'tab.dashboard-plus-grid': "Quadro d'insieme", 'sub.dashboard-plus-grid': 'dashboard + griglia dei popoli',
'tab.research-map': 'Mappa di ricerca', 'sub.research-map': 'preghiera · impegno · regioni',
'tab.research-map-plus-grid': 'Ricerca semplice', 'sub.research-map-plus-grid': 'mappa di ricerca + griglia dei popoli',
// Rail-length form. The library's legend title 'Avanzamento della preghiera' measured 191px in the
// 176px rail label at both 1400px and 390px (2026-09-25 locale sweep) and was cut off.
'tab.prayer-map': 'Avanzamento preghiera', 'sub.prayer-map': 'mappa mondiale della preghiera',
'tab.cities-internet': 'Città con Internet', 'sub.cities-internet': 'connettività delle città chiave',
'tab.gospel-resources': 'Risorse del Vangelo', 'sub.gospel-resources': 'risorse per luogo',
'tab.regions-dashboard': 'Regioni', 'sub.regions-dashboard': 'regioni e i loro paesi',
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'la mia lista dei non raggiunti + poster',
navLabel: 'Mappe di ricerca', meta: '{n} mappe', loading: 'Caricamento di {label}…', loadFailed: 'Impossibile caricare {label}.',
manifestHint: "Questa scheda è composta da manifest.json: compila l'albero o pubblica l'albero compilato per caricarla.",
desktopOnly: 'solo desktop', lab: 'beta', experimentalSub: 'mappa sperimentale',
desktopNotice: "Quadro d'insieme funziona solo su desktop: apri questa pagina su un computer per usarlo."
},
ro: {
'sec.dashboards': 'Panouri', 'sec.prayer': 'Mobilizare la rugăciune', 'sec.gospel': 'Cercetarea Evangheliei', 'sec.experimental': 'Experimental',
'tab.dashboard-plus-grid': 'Imaginea de ansamblu', 'sub.dashboard-plus-grid': 'panou + grila popoarelor',
'tab.research-map': 'Hartă de cercetare', 'sub.research-map': 'rugăciune · implicare · regiuni',
'tab.research-map-plus-grid': 'Cercetare simplă', 'sub.research-map-plus-grid': 'hartă de cercetare + grila popoarelor',
'tab.prayer-map': 'Progresul rugăciunii', 'sub.prayer-map': 'hartă mondială a rugăciunii',
'tab.cities-internet': 'Orașe cu internet', 'sub.cities-internet': 'conectivitatea orașelor-cheie',
'tab.gospel-resources': 'Resurse ale Evangheliei', 'sub.gospel-resources': 'resurse după loc',
'tab.regions-dashboard': 'Regiuni', 'sub.regions-dashboard': 'regiunile și țările lor',
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'lista mea de neatinși + afișe',
navLabel: 'Hărți de cercetare', meta: '{n} hărți', loading: 'Se încarcă {label}…', loadFailed: '{label} nu a putut fi încărcat.',
manifestHint: 'Această filă este compusă din manifest.json — construiți arborele sau serviți arborele construit pentru a o încărca.',
desktopOnly: 'doar pe desktop', lab: 'beta', experimentalSub: 'hartă experimentală',
desktopNotice: 'Imaginea de ansamblu funcționează doar pe desktop — deschideți această pagină pe un computer pentru a o folosi.'
},
ru: {
'sec.dashboards': 'Панели', 'sec.prayer': 'Молитвенная мобилизация', 'sec.gospel': 'Исследование Евангелия', 'sec.experimental': 'Экспериментальное',
'tab.dashboard-plus-grid': 'Общая картина', 'sub.dashboard-plus-grid': 'панель + таблица народов',
'tab.research-map': 'Исследовательская карта', 'sub.research-map': 'молитва · вовлечённость · регионы',
'tab.research-map-plus-grid': 'Простое исследование', 'sub.research-map-plus-grid': 'исследовательская карта + таблица народов',
'tab.prayer-map': 'Прогресс молитвы', 'sub.prayer-map': 'простая карта молитвы мира',
'tab.cities-internet': 'Города с интернетом', 'sub.cities-internet': 'подключённость ключевых городов',
'tab.gospel-resources': 'Евангельские ресурсы', 'sub.gospel-resources': 'ресурсы по местам',
'tab.regions-dashboard': 'Регионы', 'sub.regions-dashboard': 'регионы и их страны',
'tab.upg-100': 'Список UPG-100', 'sub.upg-100': 'мой список недостигнутых + плакаты',
navLabel: 'Исследовательские карты', meta: 'Карт: {n}', loading: 'Загрузка: {label}…', loadFailed: 'Не удалось загрузить: {label}.',
manifestHint: 'Эта вкладка собирается из manifest.json — соберите дерево или разместите собранное дерево, чтобы загрузить её.',
desktopOnly: 'только на компьютере', lab: 'бета', experimentalSub: 'экспериментальная карта',
desktopNotice: '«Общая картина» работает только на компьютере — откройте эту страницу на компьютере.'
},
ar: {
'sec.dashboards': 'لوحات المعلومات', 'sec.prayer': 'التعبئة للصلاة', 'sec.gospel': 'أبحاث الإنجيل', 'sec.experimental': 'تجريبي',
'tab.dashboard-plus-grid': 'الصورة الكاملة', 'sub.dashboard-plus-grid': 'لوحة معلومات + شبكة الشعوب',
'tab.research-map': 'خريطة البحث', 'sub.research-map': 'الصلاة · الانخراط · المناطق',
'tab.research-map-plus-grid': 'بحث مبسّط', 'sub.research-map-plus-grid': 'خريطة البحث + شبكة الشعوب',
'tab.prayer-map': 'تقدم الصلاة', 'sub.prayer-map': 'خريطة عالمية مبسّطة للصلاة',
'tab.cities-internet': 'مدن متصلة بالإنترنت', 'sub.cities-internet': 'اتصال المدن الرئيسية',
'tab.gospel-resources': 'موارد الإنجيل', 'sub.gospel-resources': 'الموارد حسب المكان',
'tab.regions-dashboard': 'المناطق', 'sub.regions-dashboard': 'المناطق ودولها',
'tab.upg-100': 'قائمة UPG-100', 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات',
navLabel: 'خرائط البحث', meta: 'عدد الخرائط: {n}', loading: 'جارٍ تحميل {label}…', loadFailed: 'تعذّر تحميل {label}.',
manifestHint: 'تُبنى علامة التبويب هذه من manifest.json — ابنِ الشجرة أو قدّم الشجرة المبنية لتحميلها.',
desktopOnly: 'على الحاسوب فقط', lab: 'تجريبي', experimentalSub: 'خريطة تجريبية',
desktopNotice: 'تعمل «الصورة الكاملة» على الحاسوب فقط — افتح هذه الصفحة على حاسوب لاستخدامها.'
},
hi: {
'sec.dashboards': 'डैशबोर्ड', 'sec.prayer': 'प्रार्थना संचालन', 'sec.gospel': 'सुसमाचार शोध', 'sec.experimental': 'प्रायोगिक',
'tab.dashboard-plus-grid': 'संपूर्ण चित्र', 'sub.dashboard-plus-grid': 'डैशबोर्ड + जनसमूहों की ग्रिड',
'tab.research-map': 'शोध मानचित्र', 'sub.research-map': 'प्रार्थना · सहभागिता · क्षेत्र',
'tab.research-map-plus-grid': 'सरल शोध', 'sub.research-map-plus-grid': 'शोध मानचित्र + जनसमूहों की ग्रिड',
'tab.prayer-map': 'प्रार्थना प्रगति', 'sub.prayer-map': 'सरल विश्व प्रार्थना मानचित्र',
'tab.cities-internet': 'इंटरनेट वाले शहर', 'sub.cities-internet': 'प्रमुख शहरों की कनेक्टिविटी',
'tab.gospel-resources': 'सुसमाचार संसाधन', 'sub.gospel-resources': 'स्थान के अनुसार संसाधन',
'tab.regions-dashboard': 'क्षेत्र', 'sub.regions-dashboard': 'क्षेत्र और उनके देश',
'tab.upg-100': 'UPG-100 सूची', 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर',
navLabel: 'शोध मानचित्र', meta: '{n} मानचित्र', loading: '{label} लोड हो रहा है…', loadFailed: '{label} लोड नहीं हो सका।',
manifestHint: 'यह टैब manifest.json से बनता है — इसे लोड करने के लिए ट्री बनाएँ या बना हुआ ट्री सर्व करें।',
desktopOnly: 'केवल डेस्कटॉप', lab: 'बीटा', experimentalSub: 'प्रायोगिक मानचित्र',
desktopNotice: 'संपूर्ण चित्र केवल डेस्कटॉप पर काम करता है — इसे उपयोग करने के लिए यह पेज कंप्यूटर पर खोलें।'
},
zh: {
'sec.dashboards': '仪表板', 'sec.prayer': '祷告动员', 'sec.gospel': '福音研究', 'sec.experimental': '实验性',
'tab.dashboard-plus-grid': '全景', 'sub.dashboard-plus-grid': '仪表板 + 民族网格',
'tab.research-map': '研究地图', 'sub.research-map': '祷告 · 参与 · 区域',
'tab.research-map-plus-grid': '简易研究', 'sub.research-map-plus-grid': '研究地图 + 民族网格',
'tab.prayer-map': '祷告进度', 'sub.prayer-map': '简易世界祷告地图',
'tab.cities-internet': '有互联网的城市', 'sub.cities-internet': '主要城市的网络连接',
'tab.gospel-resources': '福音资源', 'sub.gospel-resources': '按地点划分的资源',
'tab.regions-dashboard': '区域', 'sub.regions-dashboard': '区域及其国家',
'tab.upg-100': 'UPG-100 名单', 'sub.upg-100': '我的未得之民名单 + 海报',
navLabel: '研究地图', meta: '{n} 张地图', loading: '正在加载{label}…', loadFailed: '无法加载{label}。',
manifestHint: '此标签页由 manifest.json 组成——请构建目录树或提供已构建的目录树以加载它。',
desktopOnly: '仅限桌面', lab: '测试版', experimentalSub: '实验性地图',
desktopNotice: '全景仅支持桌面设备——请在电脑上打开此页面使用。'
}
}
export const LOCALES = Object.keys(STRINGS)
/** Translate `key` into `locale`, falling back to English, then to the key itself. */
export function translate(locale, key, params) {
const table = STRINGS[locale] || STRINGS.en
let s = table[key] ?? STRINGS.en[key] ?? key
if (params) for (const k in params) s = s.split('{' + k + '}').join(String(params[k]))
return s
}

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-YFGeaO/dAJ0L5HjcnZy4CPpqw6I75f/J1D5COtc33Nfny/qGJaFdtfHqXgCJbXxR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-YFGeaO/dAJ0L5HjcnZy4CPpqw6I75f/J1D5COtc33Nfny/qGJaFdtfHqXgCJbXxR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -302,7 +302,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa",
"integrity": "sha384-YFGeaO/dAJ0L5HjcnZy4CPpqw6I75f/J1D5COtc33Nfny/qGJaFdtfHqXgCJbXxR",
"elements": [
"doxa-maps-spa"
],
@ -327,7 +327,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-YFGeaO/dAJ0L5HjcnZy4CPpqw6I75f/J1D5COtc33Nfny/qGJaFdtfHqXgCJbXxR\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{