Merge main (23976fb0) after landing 9

Batch 1's content is on main as de24e633, studio's re-application rather
than my cf8cc929, so the commit is not an ancestor and only the CONTENT
test says it landed: RegionSectionCard.vue on origin/main is byte-identical
to mine.

Three conflicts, none resolved by choosing a side:

  gates:post — both sides added tokens. Mine brought
  check:shadow-css-parses, main brought check:no-silent-engine and
  check:i18n-stopped-scope. Taking either side would have dropped the
  other's gates, so the tokens are unioned: 81, none lost.

  check-regions-uses-library.mjs — add/add. Mine is the superset; the only
  two lines unique to main's copy are ones batch 2 deliberately replaced,
  generalising the import assertion to @map/components/<dir>/<name> so
  ProgressBar in charts/ resolves.

  reference-library-index.md — derived, so regenerated rather than merged
  by hand. 113 items.

Verified after merging by content and by authorship, not by reading a diff:
all four batch-2 components, the CLDR vendor and the gate survive; the
plugin still renders them and still ships no .rd-card CSS; SJM -> SJ and
NOR -> NO hold. git log over the merge base shows only my own commit
touched region-dashboards.vue, so nothing of another agent's was at risk
there. 12 gates green.
This commit is contained in:
Doxa Maps 2026-09-27 03:49:21 +00:00
commit d378b38653
150 changed files with 3460 additions and 1174 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* check-i18n-stopped-scope.mjs — createAppI18n() must succeed while a STOPPED effect scope is current.
*
* WHY (P0, the Driver's console, 2026-09-27): in Plugin Studio's gallery every tab after the first threw
* "SyntaxError: Unexpected error" from createI18n inside createAppI18n. vue-i18n's createGlobal() runs in
* scope.run() and throws UNEXPECTED_ERROR when that returns undefined; Vue 3.5 creates a new EffectScope
* INACTIVE when the currently active one is stopped; and a custom element's connectedCallback ->
* configureApp -> createAppI18n can run while the released tab's stopped scope is still current.
*
* The CONTROL comes first: a bare createI18n() inside the same stopped scope must THROW, or this harness
* does not reproduce the failure and a green result would prove nothing. Then createAppI18n() inside it
* must succeed, translate, and keep reacting to a locale change.
*/
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
let bad = 0
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + got}`) }
const { createServer } = await import('vite')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
try {
globalThis.__INTLIFY_JIT_COMPILATION__ = true
const { effectScope } = await import('vue')
const { createI18n } = await import('vue-i18n')
const mod = await server.ssrLoadModule('/library/i18n/index.js')
// Set AFTER vue loads (runtime-dom probes `document` at import). These are the two page facts
// createAppI18n reads, the URL and <html lang>, as a plain English page would give them.
globalThis.window ??= { location: { search: '' } }
globalThis.document ??= { documentElement: { lang: 'en', dir: '' } }
// A scope that ran, was stopped, and is still the current one — the released tab's.
const stopped = effectScope()
stopped.run(() => {})
stopped.stop()
const inStopped = (fn) => { stopped.on(); try { return fn() } finally { stopped.off() } }
let controlThrew = false
try { inStopped(() => createI18n({ legacy: false, locale: 'en', messages: { en: { a: 'b' } } })) } catch { controlThrew = true }
check('CONTROL: a bare createI18n() inside a stopped scope throws (the harness reproduces the bug)', controlThrew, 'it did not throw')
let i18n = null, err = null
try { i18n = inStopped(() => mod.createAppI18n()) } catch (e) { err = e }
check('createAppI18n() succeeds inside a stopped scope', !!i18n && !err, err && String(err))
if (i18n) {
check('and it translates', i18n.global.t('buttons.close') !== 'buttons.close', i18n.global.t('buttons.close'))
i18n.global.locale.value = 'ar'
await new Promise((r) => setTimeout(r, 0))
check('and its locale watcher is live (the static reader follows a runtime switch)', mod.getRuntimeLocale() === 'ar', mod.getRuntimeLocale())
// The bundle entries that still provide appI18n themselves after app.use(i18n) must not make Vue warn.
const { createApp } = await import('vue')
const warns = []; const warn = console.warn; console.warn = (...a) => warns.push(a.join(' '))
try { const app = createApp({}); app.use(i18n); app.provide('appI18n', i18n) } finally { console.warn = warn }
check('a bundle providing appI18n again after app.use(i18n) raises no Vue warning', !warns.some((w) => /appI18n/.test(w)), warns.join(' | '))
}
} finally {
await server.close()
}
if (bad) { console.error(`\nI18N-STOPPED-SCOPE: FAIL — ${bad} assertion(s)`); process.exit(1) }
console.log('\nI18N-STOPPED-SCOPE: PASS — the app i18n is created live even while a stopped effect scope is current.')

View file

@ -63,12 +63,36 @@ const PATTERN = new RegExp(
function scan(text) {
const hits = []
const lines = text.split('\n')
// ⚠ BLOCK-COMMENT STATE, not a per-line prefix test. The first version asked only whether a line
// STARTS with //, * or /*, and that is not what a comment is: it misses every CONTINUATION line of a
// /* … */ block whose text happens to begin with something else.
//
// IT COST A FALSE ACCUSATION, 2026-09-27, and the accusation was aimed at the correct fix. gospel
// fixed this defect in resources-map.vue — `const gl = engineGlobal(grProvider)`, no `||` — and
// documented what they removed in a /* */ block whose second line begins with a BACKTICK:
// `engineGlobal(grProvider) || window.mapboxgl`. engineGlobal already defaults to mapbox, so
// The prefix test saw no comment marker, matched the quoted pattern, and reported gospel as the last
// remaining offender at main 9df086e5. I relayed that to dx-shepherd as fact and it was wrong: the
// line number had also moved three times (1490 -> 1505 -> 1509) purely because their file grew, which
// is what made a stale card look like a live defect.
// ★ A GATE THAT FAILS ON THE EXPLANATION OF A FIX IS WORSE THAN NO GATE. It tells the one person who
// did the right thing that they did the wrong thing, and the cheapest way to make it green is to
// DELETE the explanation — so the gate actively destroys the reasoning it should protect. This file's
// own header quotes the pattern for the same reason, which is why the skip existed at all; it was
// just too shallow to cover the form another agent actually used.
let inBlock = false
lines.forEach((line, i) => {
// Skip comment lines: this file's own documentation quotes the bad pattern, and so does every fix
// that explains what it removed. A gate that fails on its own explanation teaches people to delete
// the explanation.
const t = line.trim()
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return
const wasInBlock = inBlock
// Track /* and */ on this line so the NEXT line knows where it stands. Deliberately simple: a
// block marker inside a string literal would fool it, and the alternative is a JS parser for a
// one-expression grep. The failure mode of over-skipping is a missed offender, which the
// --self-test guards against by keeping real forms in the MUST-MATCH list.
const opens = (line.match(/\/\*/g) || []).length
const closes = (line.match(/\*\//g) || []).length
if (opens > closes) inBlock = true
else if (closes > opens) inBlock = false
if (wasInBlock || t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return
PATTERN.lastIndex = 0
let m
while ((m = PATTERN.exec(line)) !== null) hits.push({ line: i + 1, engine: m[1], text: t.slice(0, 110) })
@ -90,6 +114,21 @@ if (SELF_TEST) {
'const gl = engineGlobal(p) || null',
'const token = mapboxToken?.value || profileConfig?.value?.tk || \'\'',
]
// ★ MULTI-LINE CASES, because the single-line list above cannot express the bug that actually
// happened: a CONTINUATION line of a /* */ block. Each entry is scanned as one text so the
// block-comment state machine is what is under test, not the prefix check.
const shouldNotMultiline = [
// gospel's real fix at resources-map.vue, verbatim in shape — the line that was falsely accused
// begins with a BACKTICK, so no prefix test could ever have skipped it.
[` /* ⚠ NEVER SWITCH ENGINES SILENTLY. This used to read
\`engineGlobal(grProvider) || window.mapboxgl\`. engineGlobal already defaults to mapbox, so
the fallback could only ever fire for provider 'maplibre'. */
const gl = engineGlobal(grProvider)`, 'a /* */ block quoting the removed fallback, then the correct fix'],
// and the inverse: a real offender AFTER a block comment closes must still be caught, or the fix
// above would have traded a false positive for a false negative.
[` /* explanation that mentions engineGlobal(p) || window.mapboxgl and then ends */
const gl = engineGlobal(p) || window.mapboxgl`, null],
]
let bad = 0
for (const s of should) {
const n = scan(s).length
@ -101,6 +140,17 @@ if (SELF_TEST) {
console.log(` ${n ? 'FALSE+ ' : 'ignored '} ${s.trim().slice(0, 62)}`)
if (n) bad++
}
// The multi-line pairs: a label means "must be IGNORED and here is why", null means "must MATCH".
// Both directions are asserted together on purpose — fixing the false positive by skipping more is
// only correct if a real offender after the block still fails.
for (const [text, why] of shouldNotMultiline) {
const n = scan(text).length
const wantHit = why === null
const ok = wantHit ? n > 0 : n === 0
if (!ok) bad++
console.log(` ${ok ? (wantHit ? 'MATCHED ' : 'ignored ') : (wantHit ? 'MISSED ' : 'FALSE+ ')} `
+ `[multi-line] ${wantHit ? 'a real offender AFTER a closed block comment' : why}`)
}
if (bad) {
console.error(`\nNO-SILENT-ENGINE-SUBSTITUTION --self-test: FAIL — ${bad} case(s) wrong.`)
process.exit(1)

View file

@ -1519,6 +1519,12 @@ function main() {
// for maps that colour every state. The curated file above is UPG-100's 34-unit partition.
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full.geojson'), 'gb-IND-ADM1-full.geojson'],
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full.meta.json'), 'gb-IND-ADM1-full.meta.json'],
// WEB copies (library/scripts/boundaries/build-web-boundaries.mjs): every feature and island kept, about
// a fifth of the bytes. The interactive maps fetch these; posters, print and build-time point-in-polygon
// keep the full files above (REPORT-WEB-BOUNDARIES.md).
[join(dataDir, 'adm0', 'world-adm0-web.geojson'), 'world-adm0-web.geojson'],
[join(dataDir, 'gb-IND-ADM1-web.geojson'), 'gb-IND-ADM1-web.geojson'],
[resolve(__dirname, '..', 'data', 'boundaries', 'gb-IND-ADM1-full-web.geojson'), 'gb-IND-ADM1-full-web.geojson'],
]) if (existsSync(from)) cpSync(from, join(bDir, to))
console.log('[generate-tree] gathered boundary geojson')
@ -1579,6 +1585,9 @@ function main() {
'boundaries/gb-CHN-ADM1.geojson',
'boundaries/gb-IND-ADM1-full.geojson', // all 36 states (Cities with Internet's NFHS layer)
'boundaries/gb-IND-ADM1-full.meta.json', // its source and licence, served beside it
'boundaries/world-adm0-web.geojson', // the web copies the interactive maps fetch
'boundaries/gb-IND-ADM1-web.geojson',
'boundaries/gb-IND-ADM1-full-web.geojson',
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
// which no element fetches and which dropin.exclude already withholds).
'people-groups/people-groups-slim.json',

View file

@ -99,6 +99,41 @@ const visible = ref(false)
// which only the reader's × turns off: scrolling away hides the pill, scrolling back shows it again.
const onScreen = ref(true)
let mapIO = null
// Whether the map's pane is the ACTIVE view. A composite that stacks views (upg-100-mfe on a phone puts the
// map pane BEHIND Prayer Resources, both position:absolute; inset:0) passes the geometry test above while
// the reader sees only the posters, because an IntersectionObserver measures geometry, not occlusion. The
// contract: a composite marks an inactive view `inert` (or hidden / aria-hidden="true"), which is right for
// assistive technology anyway, and the pill hides while any ancestor of its map carries one of those.
const paneActive = ref(true)
let paneMOs = []
const INACTIVE_ATTRS = ['inert', 'hidden', 'aria-hidden']
function ancestorsOf(el) {
const out = []
let n = el
while (n) {
if (n.nodeType === 1) out.push(n)
n = n.parentNode || n.host || null // a ShadowRoot has no parentNode; continue at its host
}
return out
}
function isInactive(el) {
return ancestorsOf(el).some((a) => a.hasAttribute('inert') || a.hasAttribute('hidden') || a.getAttribute('aria-hidden') === 'true')
}
function watchPaneActive(box) {
const check = () => {
const now = !isInactive(box)
if (now !== paneActive.value) { paneActive.value = now; if (now) nextTick(clampIntoView) }
}
check()
if (typeof MutationObserver === 'undefined') return
for (const a of ancestorsOf(box)) {
try {
const mo = new MutationObserver(check)
mo.observe(a, { attributes: true, attributeFilter: INACTIVE_ATTRS })
paneMOs.push(mo)
} catch (_) { /* detached or foreign node: skip it */ }
}
}
const hintEl = ref(null)
let watchTimer = null
let movedTo = null // the element we re-parented into, so we can put ourselves back
@ -210,7 +245,7 @@ function sheetFloor(pill) {
function clampIntoView() {
const el = hintEl.value
if (!el || !onScreen.value) return
if (!el || !onScreen.value || !paneActive.value) return
try {
// ★ ADJUST `bottom`, NEVER `transform`. My first attempt set el.style.transform and cleared it
// between measurements — both wrong here, and the diagnosis took two failed builds:
@ -303,6 +338,7 @@ onMounted(() => {
// SHOW ONLY WHILE THE MAP IS IN VIEW. The observer's root is the viewport, and it clips by every
// scrolling ancestor, so a map pane scrolled out of a composite's column reads as not intersecting.
// Less than a fifth of the map on screen counts as away: the pill needs map under it.
if (box) watchPaneActive(box)
if (box && typeof IntersectionObserver !== 'undefined') {
try {
mapIO = new IntersectionObserver((entries) => {
@ -340,6 +376,8 @@ onMounted(() => {
onBeforeUnmount(() => {
if (watchTimer) clearInterval(watchTimer)
if (mapIO) { try { mapIO.disconnect() } catch (_) {} mapIO = null }
for (const mo of paneMOs) { try { mo.disconnect() } catch (_) {} }
paneMOs = []
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
if (hostRO) { try { hostRO.disconnect() } catch (_) {} hostRO = null }
if (sheetRO) { try { sheetRO.disconnect() } catch (_) {} sheetRO = null } sheetWatched = null
@ -400,7 +438,7 @@ useShadowStyles(`
</script>
<template>
<div v-if="visible" v-show="onScreen" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<div v-if="visible" v-show="onScreen && paneActive" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<span class="pin-hint-pill">
<span class="pin-hint-dot" aria-hidden="true"></span>
{{ label }}

View file

@ -161,7 +161,7 @@ export function useCountryOutline(getMap, opts = {}) {
// Add EMPTY first, then setData after the fetch, so addLayer below stays SYNCHRONOUS and
// the outline keeps its place in the layer order.
m.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
// The tileset names the ISO field iso_3166_1_alpha_3; the geojson names it iso3.

View file

@ -434,7 +434,7 @@ export function useMapLayers(options = {}) {
// EMPTY first so the addLayer below stays synchronous and keeps its z-order slot
// (regions → shadow → pins → hitbox); setData once the fetch lands.
map.addSource('regions', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
// Tileset field is iso_3166_1_alpha_3; the geojson's is iso3. Normalise ONCE

View file

@ -45,7 +45,7 @@
* See documentation/feedback.md → Section A for the design rationale.
*/
import { watch } from 'vue'
import { watch, effectScope } from 'vue'
import { createI18n } from 'vue-i18n'
// Eager glob — all locale subfolders inlined at build time
@ -154,7 +154,14 @@ export function createAppI18n() {
document.documentElement.dir = 'rtl'
}
} catch { /* no document (SSR/tests) — nothing to reflect */ }
const i18n = createI18n({
// ★ A DETACHED EFFECT SCOPE (P0, the Driver's console, 2026-09-27). vue-i18n's createGlobal() runs
// inside scope.run() and throws UNEXPECTED_ERROR ("SyntaxError: Unexpected error") when that returns
// undefined; Vue 3.5 creates a new EffectScope INACTIVE when the currently active scope is STOPPED.
// A custom element's connectedCallback -> configureApp -> here can run while a released tab's stopped
// scope is still current (Plugin Studio's gallery, every tab after the first). effectScope(true) never
// inherits the ambient scope, so this instance and its locale watcher are always created live.
const i18nScope = effectScope(true)
const i18n = i18nScope.run(() => createI18n({
legacy: false,
globalInjection: true,
locale,
@ -162,7 +169,7 @@ export function createAppI18n() {
messages,
missingWarn: false,
fallbackWarn: false,
})
}))
// ★ INSTALLING i18n ALSO PROVIDES IT, so a bundle CANNOT forget. This is the durable half of a
// bug that is invisible by construction.
@ -195,12 +202,17 @@ export function createAppI18n() {
// Watching the instance's own locale ref is the single choke point: ProfileLoader, the SPA's
// picker and the studio's picker all move THIS ref, so none of them has to remember to also tell
// the static reader.
watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true })
i18nScope.run(() => watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true }))
const install = i18n.install.bind(i18n)
i18n.install = (app, ...rest) => {
const out = install(app, ...rest)
app.provide('appI18n', i18n)
// Several bundle entries still call app.provide('appI18n', i18n) themselves after app.use(i18n). The
// value is the same instance, but Vue warns "App already provides property with key appI18n" on every
// mount. A second provide of THIS instance under that key is made a no-op; anything else passes through.
const provide = app.provide.bind(app)
app.provide = (key, value) => (key === 'appI18n' && value === i18n ? app : provide(key, value))
return out
}
return i18n

View file

@ -131,7 +131,8 @@
"t_deaf": "فجوة الصم",
"t_resources": "الموارد حسب الشعب",
"t_languages": "اللغات حسب البلد",
"t_growth": "الوصول مقابل نمو السكان"
"t_growth": "الوصول مقابل نمو السكان",
"t_bridgereach": "لغة واحدة، كم شعبًا؟"
},
"pages": {
"listTitle": "موارد الإنجيل حسب اللغة",

View file

@ -5,6 +5,11 @@
"partial": "25–59% متصلون",
"yes": "60% أو أكثر متصلون"
},
"centre": {
"aria": "عرض",
"map": "الخريطة",
"tree": "شجرة اللغات"
},
"charts": {
"accessByBand": "الوصول بلغتهم، حسب استخدام الإنترنت في البلد",
"aria": "رسوم بيانية",
@ -149,11 +154,46 @@
"grid": "المجموعات",
"map": "الخريطة"
},
"res": {
"aria": "موارد الإنجيل للغات ضمن النطاق",
"count": "{n} موردًا في {langs} من {all} لغة ضمن النطاق",
"empty": "لم يُعثر على شيء للغات ضمن النطاق.",
"groups": "المجموعات هنا",
"note": "اللغة التي ليس لها صف هنا لم يُعثر لها على شيء في المصادر التي فُحصت.",
"resource": "المورد",
"scope": "النطاق",
"source": "المصدر",
"type": "النوع"
},
"strength": {
"moderate": "متوسطة",
"strong": "قوية"
},
"table": {
"aria": "الجدول",
"groups": "المجموعات العرقية",
"resources": "موارد الإنجيل"
},
"title": "الوصول إلى الإنجيل عبر الإنترنت",
"tree": {
"aria": "شجرة اللغات: كل عائلة لغوية لهذه المجموعات العرقية، ملوّنة بحسب ما وُجد في كل لغة",
"branch": "فرع",
"dialect": "لهجة",
"family": "عائلة",
"groupsN": "{n} مجموعة عرقية",
"inherited": "لون لغتها: لا يفهرس أي مصدر الموارد بحسب اللهجة.",
"language": "لغة",
"languagesHere": "لغات هنا",
"legendTitle": "بلغتهم",
"levels": "عائلة › فرع › لغة › لهجة. تُظهر العائلة أو الفرع أسوأ لون بداخله. وتُظهر اللهجة لون لغتها بدرجة أفتح: تُفهرس الموارد لكل لغة لا لكل لهجة. انقر دائرة لحصر اللوحة فيها.",
"sources": "الشجرة: Glottolog (CC BY 4.0). الموارد: eBible وFaith Comes By Hearing وScripture Earth وPNG.Bible وفيلم يسوع وGlobal Recordings وغيرها، حسب ISO 639-3.",
"st": {
"g": "وُجد العهد الجديد أو الكتاب المقدس كاملًا",
"r": "لم يُعثر على شيء",
"y": "وُجد شيء لكن بلا عهد جديد"
},
"via": "أيضًا على هذا المسار:"
},
"view": {
"aria": "العرض",
"board": "اللوحة",

View file

@ -113,7 +113,13 @@
"colCountry": "الدولة",
"colLanguage": "اللغة",
"colPopulation": "عدد السكان",
"colReligion": "الدين"
"colReligion": "الدين",
"colPctChristian": "% مسيحيون",
"colAdjacency": "التجاور",
"colCode": "الرمز",
"sortMobilization": "التعبئة",
"sortFrontier": "المناطق الحدودية أولًا",
"colName": "الاسم"
},
"fc": {
"clusterBy": "التجميع حسب",
@ -124,7 +130,17 @@
"legendPetal": "جار مُبشَّر (تفصيلي، إحداثيات حقيقية)",
"legendHelp": "انقر دائرة عليا لإعادة التجميع · انقر نواة لتتفتح بتلاتها",
"clustering": "جارٍ تجميع الحصاد حسب اللغة…",
"signLanguage": "لغة الإشارة"
"signLanguage": "لغة الإشارة",
"mode": {
"family": "العائلة",
"language": "اللغة",
"dialect": "اللهجة"
},
"size": {
"fewerBigger": "أقل · أكبر",
"more": "أكثر",
"most": "الأكثر"
}
},
"mc": {
"greenEncoding": "ترميز اللون الأخضر",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "عرض الخريطة أو الزهرة",
"loading": "جارٍ تحميل حقل الحصاد…",
"unreachedTarget": "هدف غير مُبشَّر"
"unreachedTarget": "هدف غير مُبشَّر",
"map": "الخريطة",
"flower": "الزهرة",
"details": "التفاصيل",
"senders": "مُرسِلون ←",
"clustersHidden": "مجموعات · {n} مخفية",
"legendSend": "SEND (متصل = اللغة نفسها · متقطع = مجموعة · منقط = داخل الدولة)",
"legendSelection": "التحديد · نصف القطر = لوغاريتم السكان · لوقا 10:2",
"navySend": "الكحلي = SEND",
"goldMesh": "الذهبي = شبكة اللغة الواحدة"
},
"flower": {
"pickTarget": "اختر هدفًا أحمر على الخريطة —",

View file

@ -32,5 +32,61 @@
"readyPins": "جاهز · دبابيس الشعوب في الدول: {pins} · انقر أي دبوس",
"readyLanguage": "جاهز · الدبابيس: {pins} · تغطية العائلات {coverage}% · أعلى 25 = {top}%",
"readyReligion": "الدبابيس: {pins} · الأكثر شيوعًا: {religion} ({count})"
},
"ui": {
"chooseView": "اختر عرضًا",
"search": "ابحث بالشعب أو المكان أو اللغة أو الدين…",
"baseMap": "الخريطة الأساسية",
"dataSources": "مصادر البيانات",
"legend": "مفتاح الخريطة",
"openLegend": "افتح مفتاح الخريطة",
"clearSelectionEsc": "إلغاء التحديد (Esc)",
"closeEsc": "إغلاق (Esc)",
"close": "إغلاق",
"retry": "أعد المحاولة",
"loadingMap": "جارٍ تحميل الخريطة",
"flyToPin": "انتقل إلى الدبوس",
"primaryReligion": "الدين الرئيسي",
"ropHierarchy": "تسلسل ROP الهرمي"
},
"pill": {
"religion": "توزيع الأديان · انقر دبوسًا للتفاصيل",
"ropTree": "تسلسل ROP الهرمي · الكتلة ← المجموعة ← الشعب ← PGIC",
"languageFamilies": "العائلات العرقية اللغوية (PPLR) · انقر دبوسًا للتفاصيل",
"sunburst": "تسلسل ROP الهرمي · الكتلة ← مجموعة الشعوب · مرّر للتكبير",
"pins": "كل دبابيس الشعوب في الدول · انقر لمعرّفات الأنظمة",
"coverage": "تغطية الترجمة · التظليل = متوسط اللغات المعبّأة لكل دولة · انقر دبوسًا أو دولة",
"rosetta": "جدول معرّفات الأنظمة · اللون = المعرّفات الموجودة · ★ = صف نموذجي"
},
"legendTitle": {
"avgLocales": "متوسط اللغات / {n}",
"idCoverage": "تغطية المعرّفات"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD، الثلاثة",
"imbOnly": "IMB فقط",
"jpOnly": "JP فقط",
"neither": "لا هذا ولا ذاك"
},
"samplePg": "★ شعب نموذجي",
"missingIds": "لم تُربط بعد في PPLR: {ids}. الصف الفارغ نقص في البيانات وليس خطأ."
},
"popup": {
"noName": "(بلا اسم)",
"country": "الدولة",
"religion": "الدين",
"language": "اللغة",
"family": "العائلة",
"affinityBloc": "الكتلة",
"cluster": "المجموعة",
"peopleCluster": "مجموعة الشعوب",
"peopleGroup": "الشعب",
"pgCount": "الشعوب",
"avgLocalesFilled": "متوسط اللغات المعبّأة",
"iso3Country": "رمز الدولة ISO-3"
},
"card": {
"nameInLocales": "الاسم في {n} لغة"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Die Gehörlosen-Lücke",
"t_resources": "Ressourcen nach Volksgruppe",
"t_languages": "Sprachen nach Land",
"t_growth": "Zugang vs. Bevölkerungswachstum"
"t_growth": "Zugang vs. Bevölkerungswachstum",
"t_bridgereach": "Eine Sprache, wie viele Völker"
},
"pages": {
"listTitle": "Evangeliums-Ressourcen nach Sprache",

View file

@ -5,6 +5,11 @@
"partial": "25–59 % online",
"yes": "60 % oder mehr online"
},
"centre": {
"aria": "Anzeigen",
"map": "Karte",
"tree": "Sprachbaum"
},
"charts": {
"accessByBand": "Zugang in eigener Sprache, nach Internetnutzung im Land",
"aria": "Diagramme",
@ -149,11 +154,46 @@
"grid": "Gruppen",
"map": "Karte"
},
"res": {
"aria": "Evangeliums-Ressourcen für die Sprachen im Auswahlbereich",
"count": "{n} Ressourcen in {langs} von {all} Sprachen im Auswahlbereich",
"empty": "Für die Sprachen im Auswahlbereich wurde nichts gefunden.",
"groups": "Gruppen hier",
"note": "Eine Sprache ohne Zeile hier hat in den geprüften Quellen nichts.",
"resource": "Ressource",
"scope": "Umfang",
"source": "Quelle",
"type": "Art"
},
"strength": {
"moderate": "mittel",
"strong": "stark"
},
"table": {
"aria": "Tabelle",
"groups": "Volksgruppen",
"resources": "Evangeliums-Ressourcen"
},
"title": "Zugang zum Evangelium über das Internet",
"tree": {
"aria": "Sprachbaum: jede Sprachfamilie dieser Volksgruppen, gefärbt danach, was in jeder Sprache gefunden wurde",
"branch": "Zweig",
"dialect": "Dialekt",
"family": "Familie",
"groupsN": "{n} Volksgruppen",
"inherited": "Farbe ihrer Sprache: Keine Quelle erfasst Ressourcen nach Dialekt.",
"language": "Sprache",
"languagesHere": "Sprachen hier",
"legendTitle": "In ihrer eigenen Sprache",
"levels": "Familie › Zweig › Sprache › Dialekt. Eine Familie oder ein Zweig zeigt die schlechteste Farbe darin. Ein Dialekt zeigt die Farbe seiner Sprache, heller: Ressourcen werden pro Sprache erfasst, nicht pro Dialekt. Klicken Sie auf einen Kreis, um die Übersicht darauf einzugrenzen.",
"sources": "Baum: Glottolog (CC BY 4.0). Ressourcen: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, JESUS-Film, Global Recordings und weitere, nach ISO 639-3.",
"st": {
"g": "Neues Testament oder ganze Bibel gefunden",
"r": "Nichts gefunden",
"y": "Etwas gefunden, aber kein Neues Testament"
},
"via": "Auch auf diesem Pfad:"
},
"view": {
"aria": "Ansicht",
"board": "Übersicht",

View file

@ -113,7 +113,13 @@
"colCountry": "Land",
"colLanguage": "Sprache",
"colPopulation": "Bevölkerung",
"colReligion": "Religion"
"colReligion": "Religion",
"colPctChristian": "% Christen",
"colAdjacency": "Nachbarschaft",
"colCode": "Kennung",
"sortMobilization": "Mobilisierung",
"sortFrontier": "Grenzgebiete zuerst",
"colName": "Name"
},
"fc": {
"clusterBy": "Gruppieren nach",
@ -124,7 +130,17 @@
"legendPetal": "Erreichter Nachbar (fein, echte Koordinaten)",
"legendHelp": "oberen Kreis anklicken zum Neugruppieren · Kern anklicken, um die Blütenblätter zu öffnen",
"clustering": "Die Ernte wird nach Sprache gruppiert…",
"signLanguage": "Gebärdensprache"
"signLanguage": "Gebärdensprache",
"mode": {
"family": "Familie",
"language": "Sprache",
"dialect": "Dialekt"
},
"size": {
"fewerBigger": "weniger · größer",
"more": "mehr",
"most": "am meisten"
}
},
"mc": {
"greenEncoding": "Kodierung der Grünfarbe",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Karten- oder Blumenansicht",
"loading": "Erntefeld wird geladen…",
"unreachedTarget": "unerreichtes Ziel"
"unreachedTarget": "unerreichtes Ziel",
"map": "Karte",
"flower": "Blume",
"details": "Details",
"senders": "Sender →",
"clustersHidden": "Cluster · {n} ausgeblendet",
"legendSend": "SEND (durchgezogen = gleiche Sprache · gestrichelt = Cluster · gepunktet = im Land)",
"legendSelection": "Auswahl · Radius = log. Bevölkerung · Lukas 10,2",
"navySend": "Marineblau = SEND",
"goldMesh": "Gold = Netz gleicher Sprache"
},
"flower": {
"pickTarget": "Ein rotes Ziel auf der Karte wählen —",

View file

@ -32,5 +32,61 @@
"readyPins": "Bereit · Pins: {pins} · einen Pin anklicken",
"readyLanguage": "Bereit · Pins: {pins} · Familienabdeckung {coverage} % · Top 25 = {top} %",
"readyReligion": "Pins: {pins} · am häufigsten: {religion} ({count})"
},
"ui": {
"chooseView": "Ansicht wählen",
"search": "Nach Volk, Ort, Sprache, Religion suchen…",
"baseMap": "Grundkarte",
"dataSources": "Datenquellen",
"legend": "Legende",
"openLegend": "Legende öffnen",
"clearSelectionEsc": "Auswahl aufheben (Esc)",
"closeEsc": "Schließen (Esc)",
"close": "Schließen",
"retry": "Erneut versuchen",
"loadingMap": "Karte wird geladen",
"flyToPin": "Zum Pin fliegen",
"primaryReligion": "Hauptreligion",
"ropHierarchy": "ROP-Hierarchie"
},
"pill": {
"religion": "Verteilung der Religionen · Pin anklicken für Details",
"ropTree": "ROP-Hierarchie · Affinitätsblock → Cluster → Volk → PGIC",
"languageFamilies": "Ethnolinguistische Familien (PPLR) · Pin anklicken für Details",
"sunburst": "ROP-Hierarchie · Affinitätsblock → Volkscluster · scrollen zum Zoomen",
"pins": "Alle Volksgruppen-Pins je Land · anklicken für systemübergreifende IDs",
"coverage": "Übersetzungsabdeckung · Färbung = durchschnittlich gefüllte Sprachen je Land · Pin oder Land anklicken",
"rosetta": "ID-Rosetta · Farbe = vorhandene IDs · ★ = Beispielzeile"
},
"legendTitle": {
"avgLocales": "Durchschnitt Sprachen / {n}",
"idCoverage": "ID-Abdeckung"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, alle drei",
"imbOnly": "Nur IMB",
"jpOnly": "Nur JP",
"neither": "keins von beiden"
},
"samplePg": "★ Beispiel-Volk",
"missingIds": "In PPLR noch nicht zugeordnet: {ids}. Eine leere Zeile ist eine Datenlücke, kein Fehler."
},
"popup": {
"noName": "(ohne Namen)",
"country": "Land",
"religion": "Religion",
"language": "Sprache",
"family": "Familie",
"affinityBloc": "Affinitätsblock",
"cluster": "Cluster",
"peopleCluster": "Volkscluster",
"peopleGroup": "Volk",
"pgCount": "Völker",
"avgLocalesFilled": "Durchschnittlich gefüllte Sprachen",
"iso3Country": "ISO-3-Land"
},
"card": {
"nameInLocales": "Name in {n} Sprachen"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "The Deaf Gap",
"t_resources": "Resources by People Group",
"t_languages": "Languages by Country",
"t_growth": "Access vs Population Growth"
"t_growth": "Access vs Population Growth",
"t_bridgereach": "One Language, How Many Peoples"
},
"pages": {
"listTitle": "Gospel Resources by Language",

View file

@ -5,6 +5,11 @@
"partial": "25-59% online",
"yes": "60% or more online"
},
"centre": {
"aria": "Show",
"map": "Map",
"tree": "Language tree"
},
"charts": {
"accessByBand": "Own-language access, by country internet use",
"aria": "Charts",
@ -149,11 +154,46 @@
"grid": "Groups",
"map": "Map"
},
"res": {
"aria": "Gospel resources for the languages in scope",
"count": "{n} resources in {langs} of {all} languages in scope",
"empty": "Nothing found for the languages in scope.",
"groups": "Groups here",
"note": "A language with no row here has nothing found in the sources checked.",
"resource": "Resource",
"scope": "Scope",
"source": "Source",
"type": "Type"
},
"strength": {
"moderate": "moderate",
"strong": "strong"
},
"table": {
"aria": "Table",
"groups": "People groups",
"resources": "Gospel resources"
},
"title": "Gospel access through the internet",
"tree": {
"aria": "Language tree: every language family of these people groups, coloured by what is found in each language",
"branch": "Branch",
"dialect": "Dialect",
"family": "Family",
"groupsN": "{n} people groups",
"inherited": "Colour of its language: no source catalogues resources by dialect.",
"language": "Language",
"languagesHere": "languages here",
"legendTitle": "In their own language",
"levels": "Family › branch › language › dialect. A family or branch shows the worst colour inside it. A dialect shows its language's colour, lighter: resources are catalogued per language, not per dialect. Click a circle to narrow the board to it.",
"sources": "Tree: Glottolog (CC BY 4.0). Resources: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, JESUS Film, Global Recordings and others, keyed by ISO 639-3.",
"st": {
"g": "New Testament or full Bible found",
"r": "Nothing found",
"y": "Something found, but no New Testament"
},
"via": "Also on this path:"
},
"view": {
"aria": "View",
"board": "Board",

View file

@ -113,7 +113,13 @@
"colCountry": "Country",
"colLanguage": "Language",
"colPopulation": "Population",
"colReligion": "Religion"
"colReligion": "Religion",
"colPctChristian": "% Christian",
"colAdjacency": "Adjacency",
"colCode": "Code",
"sortMobilization": "Mobilization",
"sortFrontier": "Frontier first",
"colName": "Name"
},
"fc": {
"clusterBy": "Cluster by",
@ -124,7 +130,17 @@
"legendPetal": "Reached neighbor (granular, real coordinates)",
"legendHelp": "click a top circle to re-cluster · click a core to bloom its petals",
"clustering": "Clustering the harvest by language…",
"signLanguage": "Sign Language"
"signLanguage": "Sign Language",
"mode": {
"family": "Family",
"language": "Language",
"dialect": "Dialect"
},
"size": {
"fewerBigger": "fewer · bigger",
"more": "more",
"most": "most"
}
},
"mc": {
"greenEncoding": "Green color encoding",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Map or flower view",
"loading": "Loading the harvest field…",
"unreachedTarget": "unreached target"
"unreachedTarget": "unreached target",
"map": "Map",
"flower": "Flower",
"details": "Details",
"senders": "senders →",
"clustersHidden": "clusters · {n} hidden",
"legendSend": "SEND (solid = same language · dashed = cluster · dotted = in-country)",
"legendSelection": "selection · radius = log of population · Luke 10:2",
"navySend": "navy = SEND",
"goldMesh": "gold = same-language mesh"
},
"flower": {
"pickTarget": "Pick a red target on the map —",

View file

@ -32,5 +32,61 @@
"readyPins": "Ready · {pins} PG-in-country pins · click any pin",
"readyLanguage": "Ready · {pins} pins · {coverage}% family coverage · top-25 = {top}%",
"readyReligion": "{pins} pins · {religion} most common ({count})"
},
"ui": {
"chooseView": "Choose a view",
"search": "Search by people, place, language, religion…",
"baseMap": "Base map",
"dataSources": "Data sources",
"legend": "Legend",
"openLegend": "Open legend",
"clearSelectionEsc": "Clear selection (Esc)",
"closeEsc": "Close (Esc)",
"close": "Close",
"retry": "Retry",
"loadingMap": "Loading map",
"flyToPin": "Fly to pin",
"primaryReligion": "Primary religion",
"ropHierarchy": "ROP hierarchy"
},
"pill": {
"religion": "Religion distribution · click a pin for detail",
"ropTree": "ROP hierarchy · Affinity Bloc → Cluster → People Group → PGIC",
"languageFamilies": "Ethnolinguistic families (PPLR) · click a pin for detail",
"sunburst": "ROP hierarchy · Affinity Bloc → People Cluster · scroll to zoom",
"pins": "All people-group-in-country pins · click for cross-system IDs",
"coverage": "Translation coverage · shading = average locales filled per country · click a pin or a country",
"rosetta": "Cross-system ID Rosetta · colour = which IDs exist · ★ = sample row"
},
"legendTitle": {
"avgLocales": "Average locales / {n}",
"idCoverage": "ID coverage"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, all three",
"imbOnly": "IMB only",
"jpOnly": "JP only",
"neither": "neither"
},
"samplePg": "★ sample people group",
"missingIds": "Not yet cross-mapped in PPLR: {ids}. An empty row is a data gap, not an error."
},
"popup": {
"noName": "(no name)",
"country": "Country",
"religion": "Religion",
"language": "Language",
"family": "Family",
"affinityBloc": "Affinity Bloc",
"cluster": "Cluster",
"peopleCluster": "People cluster",
"peopleGroup": "People group",
"pgCount": "People groups",
"avgLocalesFilled": "Average locales filled",
"iso3Country": "ISO-3 country"
},
"card": {
"nameInLocales": "Name in {n} locales"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "La brecha sorda",
"t_resources": "Recursos por pueblo",
"t_languages": "Idiomas por país",
"t_growth": "Acceso vs crecimiento"
"t_growth": "Acceso vs crecimiento",
"t_bridgereach": "Una lengua, cuántos pueblos"
},
"pages": {
"listTitle": "Recursos del evangelio por idioma",

View file

@ -5,6 +5,11 @@
"partial": "25–59 % en línea",
"yes": "60 % o más en línea"
},
"centre": {
"aria": "Mostrar",
"map": "Mapa",
"tree": "Árbol de idiomas"
},
"charts": {
"accessByBand": "Acceso en su idioma, según el uso de internet del país",
"aria": "Gráficos",
@ -149,11 +154,46 @@
"grid": "Grupos",
"map": "Mapa"
},
"res": {
"aria": "Recursos del evangelio para los idiomas en la selección",
"count": "{n} recursos en {langs} de {all} idiomas en la selección",
"empty": "No se encontró nada para los idiomas en la selección.",
"groups": "Grupos aquí",
"note": "Un idioma sin fila aquí no tiene nada en las fuentes consultadas.",
"resource": "Recurso",
"scope": "Alcance",
"source": "Fuente",
"type": "Tipo"
},
"strength": {
"moderate": "moderada",
"strong": "fuerte"
},
"table": {
"aria": "Tabla",
"groups": "Grupos étnicos",
"resources": "Recursos del evangelio"
},
"title": "Acceso al evangelio a través de internet",
"tree": {
"aria": "Árbol de idiomas: cada familia lingüística de estos grupos étnicos, coloreada según lo que existe en cada idioma",
"branch": "Rama",
"dialect": "Dialecto",
"family": "Familia",
"groupsN": "{n} grupos étnicos",
"inherited": "Color de su idioma: ninguna fuente cataloga recursos por dialecto.",
"language": "Idioma",
"languagesHere": "idiomas aquí",
"legendTitle": "En su propio idioma",
"levels": "Familia › rama › idioma › dialecto. Una familia o rama muestra el peor color que contiene. Un dialecto muestra el color de su idioma, más claro: los recursos se catalogan por idioma, no por dialecto. Haga clic en un círculo para limitar el tablero a él.",
"sources": "Árbol: Glottolog (CC BY 4.0). Recursos: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, película JESÚS, Global Recordings y otros, por ISO 639-3.",
"st": {
"g": "Nuevo Testamento o Biblia completa encontrados",
"r": "Nada encontrado",
"y": "Algo encontrado, pero sin Nuevo Testamento"
},
"via": "También en esta ruta:"
},
"view": {
"aria": "Vista",
"board": "Tablero",

View file

@ -113,7 +113,13 @@
"colCountry": "País",
"colLanguage": "Idioma",
"colPopulation": "Población",
"colReligion": "Religión"
"colReligion": "Religión",
"colPctChristian": "% cristianos",
"colAdjacency": "Adyacencia",
"colCode": "Código",
"sortMobilization": "Movilización",
"sortFrontier": "Frontera primero",
"colName": "Nombre"
},
"fc": {
"clusterBy": "Agrupar por",
@ -124,7 +130,17 @@
"legendPetal": "Vecino alcanzado (detallado, coordenadas reales)",
"legendHelp": "haz clic en un círculo superior para reagrupar · haz clic en un núcleo para abrir sus pétalos",
"clustering": "Agrupando la cosecha por idioma…",
"signLanguage": "Lengua de señas"
"signLanguage": "Lengua de señas",
"mode": {
"family": "Familia",
"language": "Idioma",
"dialect": "Dialecto"
},
"size": {
"fewerBigger": "menos · más grandes",
"more": "más",
"most": "la mayoría"
}
},
"mc": {
"greenEncoding": "Codificación del color verde",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vista de mapa o de flor",
"loading": "Cargando el campo de la cosecha…",
"unreachedTarget": "objetivo no alcanzado"
"unreachedTarget": "objetivo no alcanzado",
"map": "Mapa",
"flower": "Flor",
"details": "Detalles",
"senders": "emisores →",
"clustersHidden": "clústeres · {n} ocultos",
"legendSend": "SEND (continua = mismo idioma · discontinua = clúster · punteada = en el país)",
"legendSelection": "selección · radio = log de la población · Lucas 10:2",
"navySend": "azul marino = SEND",
"goldMesh": "dorado = red de un mismo idioma"
},
"flower": {
"pickTarget": "Elige un objetivo rojo en el mapa —",

View file

@ -32,5 +32,61 @@
"readyPins": "Listo · pines: {pins} · haz clic en un pin",
"readyLanguage": "Listo · pines: {pins} · cobertura de familias {coverage} % · top 25 = {top} %",
"readyReligion": "Pines: {pins} · más común: {religion} ({count})"
},
"ui": {
"chooseView": "Elige una vista",
"search": "Buscar por pueblo, lugar, idioma, religión…",
"baseMap": "Mapa base",
"dataSources": "Fuentes de datos",
"legend": "Leyenda",
"openLegend": "Abrir la leyenda",
"clearSelectionEsc": "Borrar la selección (Esc)",
"closeEsc": "Cerrar (Esc)",
"close": "Cerrar",
"retry": "Reintentar",
"loadingMap": "Cargando el mapa",
"flyToPin": "Ir al pin",
"primaryReligion": "Religión principal",
"ropHierarchy": "Jerarquía ROP"
},
"pill": {
"religion": "Distribución de religiones · haz clic en un pin para ver detalles",
"ropTree": "Jerarquía ROP · bloque de afinidad → clúster → pueblo → PGIC",
"languageFamilies": "Familias etnolingüísticas (PPLR) · haz clic en un pin para ver detalles",
"sunburst": "Jerarquía ROP · bloque de afinidad → clúster de pueblos · desplázate para ampliar",
"pins": "Todos los pines de pueblos por país · haz clic para ver los identificadores de cada sistema",
"coverage": "Cobertura de traducción · sombreado = promedio de idiomas completados por país · haz clic en un pin o un país",
"rosetta": "Rosetta de identificadores · color = identificadores existentes · ★ = fila de ejemplo"
},
"legendTitle": {
"avgLocales": "Promedio de idiomas / {n}",
"idCoverage": "Cobertura de identificadores"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, los tres",
"imbOnly": "Solo IMB",
"jpOnly": "Solo JP",
"neither": "ninguno"
},
"samplePg": "★ pueblo de ejemplo",
"missingIds": "Aún sin vincular en PPLR: {ids}. Una fila vacía es un hueco en los datos, no un error."
},
"popup": {
"noName": "(sin nombre)",
"country": "País",
"religion": "Religión",
"language": "Idioma",
"family": "Familia",
"affinityBloc": "Bloque de afinidad",
"cluster": "Clúster",
"peopleCluster": "Clúster de pueblos",
"peopleGroup": "Pueblo",
"pgCount": "Pueblos",
"avgLocalesFilled": "Promedio de idiomas completados",
"iso3Country": "País ISO-3"
},
"card": {
"nameInLocales": "Nombre en {n} idiomas"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "L'écart sourd",
"t_resources": "Ressources par peuple",
"t_languages": "Langues par pays",
"t_growth": "Accès vs croissance"
"t_growth": "Accès vs croissance",
"t_bridgereach": "Une langue, combien de peuples"
},
"pages": {
"listTitle": "Ressources de l'Évangile par langue",

View file

@ -5,6 +5,11 @@
"partial": "25–59 % en ligne",
"yes": "60 % ou plus en ligne"
},
"centre": {
"aria": "Afficher",
"map": "Carte",
"tree": "Arbre des langues"
},
"charts": {
"accessByBand": "Accès dans leur langue, selon l’usage d’internet du pays",
"aria": "Graphiques",
@ -149,11 +154,46 @@
"grid": "Groupes",
"map": "Carte"
},
"res": {
"aria": "Ressources de l’Évangile pour les langues sélectionnées",
"count": "{n} ressources dans {langs} des {all} langues sélectionnées",
"empty": "Rien trouvé pour les langues sélectionnées.",
"groups": "Groupes ici",
"note": "Une langue sans ligne ici n’a rien dans les sources consultées.",
"resource": "Ressource",
"scope": "Portée",
"source": "Source",
"type": "Type"
},
"strength": {
"moderate": "moyenne",
"strong": "forte"
},
"table": {
"aria": "Tableau",
"groups": "Groupes ethniques",
"resources": "Ressources de l’Évangile"
},
"title": "L’accès à l’Évangile par internet",
"tree": {
"aria": "Arbre des langues : chaque famille de langues de ces groupes ethniques, colorée selon ce qui existe dans chaque langue",
"branch": "Branche",
"dialect": "Dialecte",
"family": "Famille",
"groupsN": "{n} groupes ethniques",
"inherited": "Couleur de sa langue : aucune source ne recense les ressources par dialecte.",
"language": "Langue",
"languagesHere": "langues ici",
"legendTitle": "Dans leur propre langue",
"levels": "Famille › branche › langue › dialecte. Une famille ou une branche prend la pire couleur qu’elle contient. Un dialecte prend la couleur de sa langue, plus claire : les ressources sont recensées par langue, pas par dialecte. Cliquez sur un cercle pour y restreindre le tableau.",
"sources": "Arbre : Glottolog (CC BY 4.0). Ressources : eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, film JÉSUS, Global Recordings et d’autres, par code ISO 639-3.",
"st": {
"g": "Nouveau Testament ou Bible complète trouvé",
"r": "Rien trouvé",
"y": "Quelque chose trouvé, mais pas de Nouveau Testament"
},
"via": "Aussi sur ce chemin :"
},
"view": {
"aria": "Vue",
"board": "Tableau",

View file

@ -113,7 +113,13 @@
"colCountry": "Pays",
"colLanguage": "Langue",
"colPopulation": "Population",
"colReligion": "Religion"
"colReligion": "Religion",
"colPctChristian": "% chrétiens",
"colAdjacency": "Proximité",
"colCode": "Code",
"sortMobilization": "Mobilisation",
"sortFrontier": "Frontières d’abord",
"colName": "Nom"
},
"fc": {
"clusterBy": "Regrouper par",
@ -124,7 +130,17 @@
"legendPetal": "Voisin atteint (détaillé, coordonnées réelles)",
"legendHelp": "cliquez sur un cercle du haut pour regrouper · cliquez sur un cœur pour ouvrir ses pétales",
"clustering": "Regroupement de la moisson par langue…",
"signLanguage": "Langue des signes"
"signLanguage": "Langue des signes",
"mode": {
"family": "Famille",
"language": "Langue",
"dialect": "Dialecte"
},
"size": {
"fewerBigger": "moins · plus grands",
"more": "plus",
"most": "le plus"
}
},
"mc": {
"greenEncoding": "Codage de la couleur verte",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vue carte ou fleur",
"loading": "Chargement du champ de la moisson…",
"unreachedTarget": "cible non atteinte"
"unreachedTarget": "cible non atteinte",
"map": "Carte",
"flower": "Fleur",
"details": "Détails",
"senders": "envoyeurs →",
"clustersHidden": "grappes · {n} masqués",
"legendSend": "SEND (plein = même langue · tirets = grappe · pointillé = dans le pays)",
"legendSelection": "sélection · rayon = log de la population · Luc 10.2",
"navySend": "bleu marine = SEND",
"goldMesh": "or = réseau de même langue"
},
"flower": {
"pickTarget": "Choisissez une cible rouge sur la carte —",

View file

@ -32,5 +32,61 @@
"readyPins": "Prêt · épingles : {pins} · cliquez sur une épingle",
"readyLanguage": "Prêt · épingles : {pins} · couverture des familles {coverage} % · top 25 = {top} %",
"readyReligion": "Épingles : {pins} · la plus fréquente : {religion} ({count})"
},
"ui": {
"chooseView": "Choisir une vue",
"search": "Rechercher par peuple, lieu, langue, religion…",
"baseMap": "Fond de carte",
"dataSources": "Sources des données",
"legend": "Légende",
"openLegend": "Ouvrir la légende",
"clearSelectionEsc": "Effacer la sélection (Échap)",
"closeEsc": "Fermer (Échap)",
"close": "Fermer",
"retry": "Réessayer",
"loadingMap": "Chargement de la carte",
"flyToPin": "Aller à l’épingle",
"primaryReligion": "Religion principale",
"ropHierarchy": "Hiérarchie ROP"
},
"pill": {
"religion": "Répartition des religions · cliquez sur une épingle pour le détail",
"ropTree": "Hiérarchie ROP · bloc d’affinité → grappe → peuple → PGIC",
"languageFamilies": "Familles ethnolinguistiques (PPLR) · cliquez sur une épingle pour le détail",
"sunburst": "Hiérarchie ROP · bloc d’affinité → grappe de peuples · faites défiler pour zoomer",
"pins": "Toutes les épingles peuple-pays · cliquez pour les identifiants des systèmes",
"coverage": "Couverture de traduction · teinte = moyenne des langues renseignées par pays · cliquez sur une épingle ou un pays",
"rosetta": "Rosetta des identifiants · couleur = identifiants présents · ★ = ligne d’exemple"
},
"legendTitle": {
"avgLocales": "Moyenne des langues / {n}",
"idCoverage": "Couverture des identifiants"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, les trois",
"imbOnly": "IMB seulement",
"jpOnly": "JP seulement",
"neither": "aucun des deux"
},
"samplePg": "★ peuple d’exemple",
"missingIds": "Pas encore mis en correspondance dans PPLR : {ids}. Une ligne vide est une lacune des données, pas une erreur."
},
"popup": {
"noName": "(sans nom)",
"country": "Pays",
"religion": "Religion",
"language": "Langue",
"family": "Famille",
"affinityBloc": "Bloc d’affinité",
"cluster": "Grappe",
"peopleCluster": "Grappe de peuples",
"peopleGroup": "Peuple",
"pgCount": "Peuples",
"avgLocalesFilled": "Moyenne des langues renseignées",
"iso3Country": "Pays ISO-3"
},
"card": {
"nameInLocales": "Nom dans {n} langues"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "बधिर कमी",
"t_resources": "जनसमूह अनुसार संसाधन",
"t_languages": "देश अनुसार भाषाएँ",
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि"
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि",
"t_bridgereach": "एक भाषा, कितने जनसमूह"
},
"pages": {
"listTitle": "भाषा अनुसार सुसमाचार संसाधन",

View file

@ -5,6 +5,11 @@
"partial": "25–59% ऑनलाइन",
"yes": "60% या अधिक ऑनलाइन"
},
"centre": {
"aria": "दिखाएँ",
"map": "मानचित्र",
"tree": "भाषा वृक्ष"
},
"charts": {
"accessByBand": "अपनी भाषा में पहुँच, देश के इंटरनेट उपयोग के अनुसार",
"aria": "चार्ट",
@ -149,11 +154,46 @@
"grid": "समूह",
"map": "मानचित्र"
},
"res": {
"aria": "दायरे की भाषाओं के लिए सुसमाचार संसाधन",
"count": "दायरे की {all} भाषाओं में से {langs} में {n} संसाधन",
"empty": "दायरे की भाषाओं के लिए कुछ नहीं मिला।",
"groups": "यहाँ समूह",
"note": "जिस भाषा की यहाँ कोई पंक्ति नहीं है, उसके लिए जाँचे गए स्रोतों में कुछ नहीं मिला।",
"resource": "संसाधन",
"scope": "दायरा",
"source": "स्रोत",
"type": "प्रकार"
},
"strength": {
"moderate": "मध्यम",
"strong": "मज़बूत"
},
"table": {
"aria": "तालिका",
"groups": "जन-समूह",
"resources": "सुसमाचार संसाधन"
},
"title": "इंटरनेट के माध्यम से सुसमाचार तक पहुँच",
"tree": {
"aria": "भाषा वृक्ष: इन जन-समूहों का हर भाषा परिवार, हर भाषा में जो मिला उसके अनुसार रंगा हुआ",
"branch": "शाखा",
"dialect": "बोली",
"family": "परिवार",
"groupsN": "{n} जन-समूह",
"inherited": "इसकी भाषा का रंग: कोई भी स्रोत बोली के अनुसार संसाधन सूचीबद्ध नहीं करता।",
"language": "भाषा",
"languagesHere": "भाषाएँ यहाँ",
"legendTitle": "उनकी अपनी भाषा में",
"levels": "परिवार › शाखा › भाषा › बोली। परिवार या शाखा अपने भीतर का सबसे खराब रंग दिखाती है। बोली अपनी भाषा का रंग हल्का दिखाती है: संसाधन भाषा के अनुसार सूचीबद्ध होते हैं, बोली के अनुसार नहीं। बोर्ड को उस तक सीमित करने के लिए किसी वृत्त पर क्लिक करें।",
"sources": "वृक्ष: Glottolog (CC BY 4.0)। संसाधन: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, यीशु फ़िल्म, Global Recordings और अन्य, ISO 639-3 के अनुसार।",
"st": {
"g": "नया नियम या पूरी बाइबल मिली",
"r": "कुछ नहीं मिला",
"y": "कुछ मिला, पर नया नियम नहीं"
},
"via": "इस पथ पर भी:"
},
"view": {
"aria": "दृश्य",
"board": "बोर्ड",

View file

@ -113,7 +113,13 @@
"colCountry": "देश",
"colLanguage": "भाषा",
"colPopulation": "जनसंख्या",
"colReligion": "धर्म"
"colReligion": "धर्म",
"colPctChristian": "% ईसाई",
"colAdjacency": "निकटता",
"colCode": "कोड",
"sortMobilization": "संग्रहण",
"sortFrontier": "सीमांत पहले",
"colName": "नाम"
},
"fc": {
"clusterBy": "समूह बनाएँ",
@ -124,7 +130,17 @@
"legendPetal": "पहुँचा पड़ोसी (बारीक, असली निर्देशांक)",
"legendHelp": "फिर से समूह बनाने के लिए ऊपर के घेरे पर क्लिक करें · पंखुड़ियाँ खोलने के लिए केंद्र पर क्लिक करें",
"clustering": "फ़सल को भाषा के अनुसार समूहित किया जा रहा है…",
"signLanguage": "सांकेतिक भाषा"
"signLanguage": "सांकेतिक भाषा",
"mode": {
"family": "परिवार",
"language": "भाषा",
"dialect": "बोली"
},
"size": {
"fewerBigger": "कम · बड़े",
"more": "अधिक",
"most": "सर्वाधिक"
}
},
"mc": {
"greenEncoding": "हरे रंग की कोडिंग",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "नक्शा या फूल दृश्य",
"loading": "फ़सल का खेत लोड हो रहा है…",
"unreachedTarget": "अपहुँचा लक्ष्य"
"unreachedTarget": "अपहुँचा लक्ष्य",
"map": "मानचित्र",
"flower": "फूल",
"details": "विवरण",
"senders": "भेजने वाले →",
"clustersHidden": "क्लस्टर · {n} छिपे",
"legendSend": "SEND (ठोस = वही भाषा · डैश = क्लस्टर · बिंदु = देश के भीतर)",
"legendSelection": "चयन · त्रिज्या = जनसंख्या का लघुगणक · लूका 10:2",
"navySend": "गहरा नीला = SEND",
"goldMesh": "सुनहरा = एक-भाषा जाल"
},
"flower": {
"pickTarget": "नक्शे पर एक लाल लक्ष्य चुनें —",

View file

@ -32,5 +32,61 @@
"readyPins": "तैयार · पिन: {pins} · किसी पिन पर क्लिक करें",
"readyLanguage": "तैयार · पिन: {pins} · परिवार कवरेज {coverage}% · शीर्ष 25 = {top}%",
"readyReligion": "पिन: {pins} · सबसे आम: {religion} ({count})"
},
"ui": {
"chooseView": "दृश्य चुनें",
"search": "जनसमूह, स्थान, भाषा, धर्म से खोजें…",
"baseMap": "आधार मानचित्र",
"dataSources": "डेटा स्रोत",
"legend": "सूचकांक",
"openLegend": "सूचकांक खोलें",
"clearSelectionEsc": "चयन हटाएँ (Esc)",
"closeEsc": "बंद करें (Esc)",
"close": "बंद करें",
"retry": "फिर से कोशिश करें",
"loadingMap": "मानचित्र लोड हो रहा है",
"flyToPin": "पिन पर जाएँ",
"primaryReligion": "प्रमुख धर्म",
"ropHierarchy": "ROP पदानुक्रम"
},
"pill": {
"religion": "धर्मों का वितरण · विवरण के लिए किसी पिन पर क्लिक करें",
"ropTree": "ROP पदानुक्रम · सम्बद्धता खंड → क्लस्टर → जनसमूह → PGIC",
"languageFamilies": "नृजातीय-भाषाई परिवार (PPLR) · विवरण के लिए किसी पिन पर क्लिक करें",
"sunburst": "ROP पदानुक्रम · सम्बद्धता खंड → जनसमूह क्लस्टर · ज़ूम के लिए स्क्रॉल करें",
"pins": "देश-वार सभी जनसमूह पिन · प्रणालियों की पहचान-संख्याओं के लिए क्लिक करें",
"coverage": "अनुवाद कवरेज · रंग = प्रति देश भरी गई भाषाओं का औसत · किसी पिन या देश पर क्लिक करें",
"rosetta": "पहचान-संख्या रोसेटा · रंग = कौन-सी पहचान-संख्याएँ हैं · ★ = नमूना पंक्ति"
},
"legendTitle": {
"avgLocales": "भाषाओं का औसत / {n}",
"idCoverage": "पहचान-संख्या कवरेज"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, तीनों",
"imbOnly": "केवल IMB",
"jpOnly": "केवल JP",
"neither": "दोनों में से कोई नहीं"
},
"samplePg": "★ नमूना जनसमूह",
"missingIds": "PPLR में अभी मिलान नहीं हुआ: {ids}। खाली पंक्ति डेटा की कमी है, त्रुटि नहीं।"
},
"popup": {
"noName": "(कोई नाम नहीं)",
"country": "देश",
"religion": "धर्म",
"language": "भाषा",
"family": "परिवार",
"affinityBloc": "सम्बद्धता खंड",
"cluster": "क्लस्टर",
"peopleCluster": "जनसमूह क्लस्टर",
"peopleGroup": "जनसमूह",
"pgCount": "जनसमूह",
"avgLocalesFilled": "भरी गई भाषाओं का औसत",
"iso3Country": "ISO-3 देश"
},
"card": {
"nameInLocales": "{n} भाषाओं में नाम"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Il divario sordo",
"t_resources": "Risorse per popolo",
"t_languages": "Lingue per paese",
"t_growth": "Accesso vs crescita"
"t_growth": "Accesso vs crescita",
"t_bridgereach": "Una lingua, quanti popoli"
},
"pages": {
"listTitle": "Risorse del Vangelo per lingua",

View file

@ -5,6 +5,11 @@
"partial": "25–59 % online",
"yes": "60 % o più online"
},
"centre": {
"aria": "Mostra",
"map": "Mappa",
"tree": "Albero delle lingue"
},
"charts": {
"accessByBand": "Accesso nella propria lingua, per uso di internet del paese",
"aria": "Grafici",
@ -149,11 +154,46 @@
"grid": "Gruppi",
"map": "Mappa"
},
"res": {
"aria": "Risorse del vangelo per le lingue selezionate",
"count": "{n} risorse in {langs} delle {all} lingue selezionate",
"empty": "Niente trovato per le lingue selezionate.",
"groups": "Gruppi qui",
"note": "Una lingua senza riga qui non ha niente nelle fonti consultate.",
"resource": "Risorsa",
"scope": "Ambito",
"source": "Fonte",
"type": "Tipo"
},
"strength": {
"moderate": "moderata",
"strong": "forte"
},
"table": {
"aria": "Tabella",
"groups": "Gruppi etnici",
"resources": "Risorse del vangelo"
},
"title": "L’accesso al vangelo tramite internet",
"tree": {
"aria": "Albero delle lingue: ogni famiglia linguistica di questi gruppi etnici, colorata secondo ciò che esiste in ogni lingua",
"branch": "Ramo",
"dialect": "Dialetto",
"family": "Famiglia",
"groupsN": "{n} gruppi etnici",
"inherited": "Colore della sua lingua: nessuna fonte cataloga le risorse per dialetto.",
"language": "Lingua",
"languagesHere": "lingue qui",
"legendTitle": "Nella propria lingua",
"levels": "Famiglia › ramo › lingua › dialetto. Una famiglia o un ramo mostra il colore peggiore al suo interno. Un dialetto mostra il colore della sua lingua, più chiaro: le risorse sono catalogate per lingua, non per dialetto. Clicca un cerchio per restringere il pannello.",
"sources": "Albero: Glottolog (CC BY 4.0). Risorse: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, film JESUS, Global Recordings e altre, per ISO 639-3.",
"st": {
"g": "Nuovo Testamento o Bibbia completa trovati",
"r": "Niente trovato",
"y": "Qualcosa trovato, ma nessun Nuovo Testamento"
},
"via": "Anche su questo percorso:"
},
"view": {
"aria": "Vista",
"board": "Pannello",

View file

@ -113,7 +113,13 @@
"colCountry": "Paese",
"colLanguage": "Lingua",
"colPopulation": "Popolazione",
"colReligion": "Religione"
"colReligion": "Religione",
"colPctChristian": "% cristiani",
"colAdjacency": "Adiacenza",
"colCode": "Codice",
"sortMobilization": "Mobilitazione",
"sortFrontier": "Prima le frontiere",
"colName": "Nome"
},
"fc": {
"clusterBy": "Raggruppa per",
@ -124,7 +130,17 @@
"legendPetal": "Vicino raggiunto (dettagliato, coordinate reali)",
"legendHelp": "clicca un cerchio in alto per raggruppare · clicca un nucleo per aprire i petali",
"clustering": "Raggruppamento del raccolto per lingua…",
"signLanguage": "Lingua dei segni"
"signLanguage": "Lingua dei segni",
"mode": {
"family": "Famiglia",
"language": "Lingua",
"dialect": "Dialetto"
},
"size": {
"fewerBigger": "meno · più grandi",
"more": "di più",
"most": "il massimo"
}
},
"mc": {
"greenEncoding": "Codifica del colore verde",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vista mappa o fiore",
"loading": "Caricamento del campo della mietitura…",
"unreachedTarget": "obiettivo non raggiunto"
"unreachedTarget": "obiettivo non raggiunto",
"map": "Mappa",
"flower": "Fiore",
"details": "Dettagli",
"senders": "mittenti →",
"clustersHidden": "cluster · {n} nascosti",
"legendSend": "SEND (continua = stessa lingua · tratteggiata = cluster · punteggiata = nel paese)",
"legendSelection": "selezione · raggio = log della popolazione · Luca 10,2",
"navySend": "blu scuro = SEND",
"goldMesh": "oro = rete della stessa lingua"
},
"flower": {
"pickTarget": "Scegli un obiettivo rosso sulla mappa —",

View file

@ -32,5 +32,61 @@
"readyPins": "Pronto · segnaposto: {pins} · clicca un segnaposto",
"readyLanguage": "Pronto · segnaposto: {pins} · copertura famiglie {coverage}% · top 25 = {top}%",
"readyReligion": "Segnaposto: {pins} · più comune: {religion} ({count})"
},
"ui": {
"chooseView": "Scegli una vista",
"search": "Cerca per popolo, luogo, lingua, religione…",
"baseMap": "Mappa di base",
"dataSources": "Fonti dei dati",
"legend": "Legenda",
"openLegend": "Apri la legenda",
"clearSelectionEsc": "Cancella la selezione (Esc)",
"closeEsc": "Chiudi (Esc)",
"close": "Chiudi",
"retry": "Riprova",
"loadingMap": "Caricamento della mappa",
"flyToPin": "Vai al segnaposto",
"primaryReligion": "Religione principale",
"ropHierarchy": "Gerarchia ROP"
},
"pill": {
"religion": "Distribuzione delle religioni · clicca un segnaposto per i dettagli",
"ropTree": "Gerarchia ROP · blocco di affinità → cluster → popolo → PGIC",
"languageFamilies": "Famiglie etnolinguistiche (PPLR) · clicca un segnaposto per i dettagli",
"sunburst": "Gerarchia ROP · blocco di affinità → cluster di popoli · scorri per ingrandire",
"pins": "Tutti i segnaposto popolo-paese · clicca per gli identificativi dei sistemi",
"coverage": "Copertura delle traduzioni · colore = media delle lingue compilate per paese · clicca un segnaposto o un paese",
"rosetta": "Rosetta degli identificativi · colore = identificativi presenti · ★ = riga di esempio"
},
"legendTitle": {
"avgLocales": "Media delle lingue / {n}",
"idCoverage": "Copertura degli identificativi"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, tutti e tre",
"imbOnly": "Solo IMB",
"jpOnly": "Solo JP",
"neither": "nessuno dei due"
},
"samplePg": "★ popolo di esempio",
"missingIds": "Non ancora collegati in PPLR: {ids}. Una riga vuota è una lacuna nei dati, non un errore."
},
"popup": {
"noName": "(senza nome)",
"country": "Paese",
"religion": "Religione",
"language": "Lingua",
"family": "Famiglia",
"affinityBloc": "Blocco di affinità",
"cluster": "Cluster",
"peopleCluster": "Cluster di popoli",
"peopleGroup": "Popolo",
"pgCount": "Popoli",
"avgLocalesFilled": "Media delle lingue compilate",
"iso3Country": "Paese ISO-3"
},
"card": {
"nameInLocales": "Nome in {n} lingue"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "A lacuna surda",
"t_resources": "Recursos por povo",
"t_languages": "Idiomas por país",
"t_growth": "Acesso vs crescimento"
"t_growth": "Acesso vs crescimento",
"t_bridgereach": "Uma língua, quantos povos"
},
"pages": {
"listTitle": "Recursos do evangelho por idioma",

View file

@ -5,6 +5,11 @@
"partial": "25–59 % online",
"yes": "60 % ou mais online"
},
"centre": {
"aria": "Mostrar",
"map": "Mapa",
"tree": "Árvore de línguas"
},
"charts": {
"accessByBand": "Acesso na própria língua, por uso de internet do país",
"aria": "Gráficos",
@ -149,11 +154,46 @@
"grid": "Grupos",
"map": "Mapa"
},
"res": {
"aria": "Recursos do evangelho para as línguas no recorte",
"count": "{n} recursos em {langs} de {all} línguas no recorte",
"empty": "Nada encontrado para as línguas no recorte.",
"groups": "Grupos aqui",
"note": "Uma língua sem linha aqui não tem nada nas fontes verificadas.",
"resource": "Recurso",
"scope": "Abrangência",
"source": "Fonte",
"type": "Tipo"
},
"strength": {
"moderate": "moderada",
"strong": "forte"
},
"table": {
"aria": "Tabela",
"groups": "Povos",
"resources": "Recursos do evangelho"
},
"title": "Acesso ao evangelho pela internet",
"tree": {
"aria": "Árvore de línguas: cada família linguística destes povos, colorida pelo que existe em cada língua",
"branch": "Ramo",
"dialect": "Dialeto",
"family": "Família",
"groupsN": "{n} povos",
"inherited": "Cor da sua língua: nenhuma fonte cataloga recursos por dialeto.",
"language": "Língua",
"languagesHere": "línguas aqui",
"legendTitle": "Na própria língua",
"levels": "Família › ramo › língua › dialeto. Uma família ou ramo mostra a pior cor que contém. Um dialeto mostra a cor da sua língua, mais clara: os recursos são catalogados por língua, não por dialeto. Clique num círculo para restringir o painel a ele.",
"sources": "Árvore: Glottolog (CC BY 4.0). Recursos: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, filme JESUS, Global Recordings e outros, por ISO 639-3.",
"st": {
"g": "Novo Testamento ou Bíblia completa encontrados",
"r": "Nada encontrado",
"y": "Algo encontrado, mas sem Novo Testamento"
},
"via": "Também neste caminho:"
},
"view": {
"aria": "Visão",
"board": "Painel",

View file

@ -113,7 +113,13 @@
"colCountry": "País",
"colLanguage": "Idioma",
"colPopulation": "População",
"colReligion": "Religião"
"colReligion": "Religião",
"colPctChristian": "% cristãos",
"colAdjacency": "Adjacência",
"colCode": "Código",
"sortMobilization": "Mobilização",
"sortFrontier": "Fronteira primeiro",
"colName": "Nome"
},
"fc": {
"clusterBy": "Agrupar por",
@ -124,7 +130,17 @@
"legendPetal": "Vizinho alcançado (detalhado, coordenadas reais)",
"legendHelp": "clique num círculo superior para reagrupar · clique num núcleo para abrir as pétalas",
"clustering": "A agrupar a colheita por idioma…",
"signLanguage": "Língua de sinais"
"signLanguage": "Língua de sinais",
"mode": {
"family": "Família",
"language": "Idioma",
"dialect": "Dialeto"
},
"size": {
"fewerBigger": "menos · maiores",
"more": "mais",
"most": "o máximo"
}
},
"mc": {
"greenEncoding": "Codificação da cor verde",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Visão de mapa ou de flor",
"loading": "Carregando o campo da colheita…",
"unreachedTarget": "alvo não alcançado"
"unreachedTarget": "alvo não alcançado",
"map": "Mapa",
"flower": "Flor",
"details": "Detalhes",
"senders": "enviadores →",
"clustersHidden": "agrupamentos · {n} ocultos",
"legendSend": "SEND (contínua = mesmo idioma · tracejada = agrupamento · pontilhada = no país)",
"legendSelection": "seleção · raio = log da população · Lucas 10:2",
"navySend": "azul-marinho = SEND",
"goldMesh": "dourado = rede do mesmo idioma"
},
"flower": {
"pickTarget": "Escolha um alvo vermelho no mapa —",

View file

@ -32,5 +32,61 @@
"readyPins": "Pronto · marcadores: {pins} · clique num marcador",
"readyLanguage": "Pronto · marcadores: {pins} · cobertura de famílias {coverage}% · top 25 = {top}%",
"readyReligion": "Marcadores: {pins} · mais comum: {religion} ({count})"
},
"ui": {
"chooseView": "Escolha uma vista",
"search": "Pesquisar por povo, lugar, idioma, religião…",
"baseMap": "Mapa base",
"dataSources": "Fontes de dados",
"legend": "Legenda",
"openLegend": "Abrir a legenda",
"clearSelectionEsc": "Limpar a seleção (Esc)",
"closeEsc": "Fechar (Esc)",
"close": "Fechar",
"retry": "Tentar novamente",
"loadingMap": "A carregar o mapa",
"flyToPin": "Ir para o marcador",
"primaryReligion": "Religião principal",
"ropHierarchy": "Hierarquia ROP"
},
"pill": {
"religion": "Distribuição das religiões · clique num marcador para ver detalhes",
"ropTree": "Hierarquia ROP · bloco de afinidade → agrupamento → povo → PGIC",
"languageFamilies": "Famílias etnolinguísticas (PPLR) · clique num marcador para ver detalhes",
"sunburst": "Hierarquia ROP · bloco de afinidade → agrupamento de povos · role para ampliar",
"pins": "Todos os marcadores povo-país · clique para ver os identificadores dos sistemas",
"coverage": "Cobertura de tradução · cor = média de idiomas preenchidos por país · clique num marcador ou num país",
"rosetta": "Rosetta de identificadores · cor = identificadores existentes · ★ = linha de exemplo"
},
"legendTitle": {
"avgLocales": "Média de idiomas / {n}",
"idCoverage": "Cobertura de identificadores"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, os três",
"imbOnly": "Apenas IMB",
"jpOnly": "Apenas JP",
"neither": "nenhum"
},
"samplePg": "★ povo de exemplo",
"missingIds": "Ainda não correspondidos no PPLR: {ids}. Uma linha vazia é uma lacuna nos dados, não um erro."
},
"popup": {
"noName": "(sem nome)",
"country": "País",
"religion": "Religião",
"language": "Idioma",
"family": "Família",
"affinityBloc": "Bloco de afinidade",
"cluster": "Agrupamento",
"peopleCluster": "Agrupamento de povos",
"peopleGroup": "Povo",
"pgCount": "Povos",
"avgLocalesFilled": "Média de idiomas preenchidos",
"iso3Country": "País ISO-3"
},
"card": {
"nameInLocales": "Nome em {n} idiomas"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Golul surd",
"t_resources": "Resurse pe popoare",
"t_languages": "Limbi pe țări",
"t_growth": "Acces vs creștere"
"t_growth": "Acces vs creștere",
"t_bridgereach": "O limbă, câte popoare"
},
"pages": {
"listTitle": "Resurse ale Evangheliei pe limbi",

View file

@ -5,6 +5,11 @@
"partial": "25–59 % online",
"yes": "60 % sau mai mult online"
},
"centre": {
"aria": "Afișează",
"map": "Hartă",
"tree": "Arborele limbilor"
},
"charts": {
"accessByBand": "Acces în propria limbă, după folosirea internetului în țară",
"aria": "Grafice",
@ -149,11 +154,46 @@
"grid": "Grupuri",
"map": "Hartă"
},
"res": {
"aria": "Resurse ale Evangheliei pentru limbile selectate",
"count": "{n} resurse în {langs} din {all} limbi selectate",
"empty": "Nu s-a găsit nimic pentru limbile selectate.",
"groups": "Grupuri aici",
"note": "O limbă fără rând aici nu are nimic în sursele verificate.",
"resource": "Resursă",
"scope": "Domeniu",
"source": "Sursă",
"type": "Tip"
},
"strength": {
"moderate": "moderată",
"strong": "puternică"
},
"table": {
"aria": "Tabel",
"groups": "Grupuri etnice",
"resources": "Resurse ale Evangheliei"
},
"title": "Accesul la Evanghelie prin internet",
"tree": {
"aria": "Arborele limbilor: fiecare familie de limbi a acestor grupuri etnice, colorată după ce există în fiecare limbă",
"branch": "Ramură",
"dialect": "Dialect",
"family": "Familie",
"groupsN": "{n} grupuri etnice",
"inherited": "Culoarea limbii sale: nicio sursă nu catalogează resursele pe dialecte.",
"language": "Limbă",
"languagesHere": "limbi aici",
"legendTitle": "În propria lor limbă",
"levels": "Familie › ramură › limbă › dialect. O familie sau o ramură arată cea mai rea culoare din interior. Un dialect arată culoarea limbii sale, mai deschisă: resursele sunt catalogate pe limbă, nu pe dialect. Faceți clic pe un cerc pentru a restrânge panoul la el.",
"sources": "Arbore: Glottolog (CC BY 4.0). Resurse: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, filmul JESUS, Global Recordings și altele, după ISO 639-3.",
"st": {
"g": "Noul Testament sau Biblia întreagă găsite",
"r": "Nimic găsit",
"y": "Ceva găsit, dar fără Noul Testament"
},
"via": "Tot pe această cale:"
},
"view": {
"aria": "Vedere",
"board": "Panou",

View file

@ -113,7 +113,13 @@
"colCountry": "Țară",
"colLanguage": "Limbă",
"colPopulation": "Populație",
"colReligion": "Religie"
"colReligion": "Religie",
"colPctChristian": "% creștini",
"colAdjacency": "Vecinătate",
"colCode": "Cod",
"sortMobilization": "Mobilizare",
"sortFrontier": "Frontiera întâi",
"colName": "Nume"
},
"fc": {
"clusterBy": "Grupați după",
@ -124,7 +130,17 @@
"legendPetal": "Vecin atins (detaliat, coordonate reale)",
"legendHelp": "clic pe un cerc de sus pentru regrupare · clic pe un nucleu pentru a-i deschide petalele",
"clustering": "Se grupează recolta după limbă…",
"signLanguage": "Limbajul semnelor"
"signLanguage": "Limbajul semnelor",
"mode": {
"family": "Familie",
"language": "Limbă",
"dialect": "Dialect"
},
"size": {
"fewerBigger": "mai puține · mai mari",
"more": "mai multe",
"most": "cele mai multe"
}
},
"mc": {
"greenEncoding": "Codificarea culorii verzi",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Vedere hartă sau floare",
"loading": "Se încarcă câmpul secerișului…",
"unreachedTarget": "țintă neatinsă"
"unreachedTarget": "țintă neatinsă",
"map": "Hartă",
"flower": "Floare",
"details": "Detalii",
"senders": "trimițători →",
"clustersHidden": "grupuri · {n} ascunse",
"legendSend": "SEND (continuă = aceeași limbă · întreruptă = grup · punctată = în țară)",
"legendSelection": "selecție · rază = log al populației · Luca 10:2",
"navySend": "bleumarin = SEND",
"goldMesh": "auriu = rețea de aceeași limbă"
},
"flower": {
"pickTarget": "Alege o țintă roșie pe hartă —",

View file

@ -32,5 +32,61 @@
"readyPins": "Gata · repere: {pins} · dați clic pe un reper",
"readyLanguage": "Gata · repere: {pins} · acoperirea familiilor {coverage}% · top 25 = {top}%",
"readyReligion": "Repere: {pins} · cea mai frecventă: {religion} ({count})"
},
"ui": {
"chooseView": "Alegeți o vedere",
"search": "Căutați după popor, loc, limbă, religie…",
"baseMap": "Hartă de bază",
"dataSources": "Surse de date",
"legend": "Legendă",
"openLegend": "Deschide legenda",
"clearSelectionEsc": "Șterge selecția (Esc)",
"closeEsc": "Închide (Esc)",
"close": "Închide",
"retry": "Reîncercați",
"loadingMap": "Se încarcă harta",
"flyToPin": "Mergi la reper",
"primaryReligion": "Religia principală",
"ropHierarchy": "Ierarhia ROP"
},
"pill": {
"religion": "Distribuția religiilor · dați clic pe un reper pentru detalii",
"ropTree": "Ierarhia ROP · bloc de afinitate → grup → popor → PGIC",
"languageFamilies": "Familii etnolingvistice (PPLR) · dați clic pe un reper pentru detalii",
"sunburst": "Ierarhia ROP · bloc de afinitate → grup de popoare · derulați pentru zoom",
"pins": "Toate reperele popor-țară · dați clic pentru identificatorii sistemelor",
"coverage": "Acoperirea traducerilor · nuanța = media limbilor completate pe țară · dați clic pe un reper sau pe o țară",
"rosetta": "Rosetta identificatorilor · culoare = identificatorii existenți · ★ = rând de exemplu"
},
"legendTitle": {
"avgLocales": "Media limbilor / {n}",
"idCoverage": "Acoperirea identificatorilor"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, toate trei",
"imbOnly": "Doar IMB",
"jpOnly": "Doar JP",
"neither": "niciunul"
},
"samplePg": "★ popor de exemplu",
"missingIds": "Încă neasociate în PPLR: {ids}. Un rând gol este o lipsă de date, nu o eroare."
},
"popup": {
"noName": "(fără nume)",
"country": "Țară",
"religion": "Religie",
"language": "Limbă",
"family": "Familie",
"affinityBloc": "Bloc de afinitate",
"cluster": "Grup",
"peopleCluster": "Grup de popoare",
"peopleGroup": "Popor",
"pgCount": "Popoare",
"avgLocalesFilled": "Media limbilor completate",
"iso3Country": "Țară ISO-3"
},
"card": {
"nameInLocales": "Numele în {n} limbi"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Пробел у глухих",
"t_resources": "Ресурсы по народам",
"t_languages": "Языки по странам",
"t_growth": "Доступ и рост населения"
"t_growth": "Доступ и рост населения",
"t_bridgereach": "Один язык — сколько народов"
},
"pages": {
"listTitle": "Ресурсы Евангелия по языкам",

View file

@ -5,6 +5,11 @@
"partial": "25–59 % в сети",
"yes": "60 % и более в сети"
},
"centre": {
"aria": "Показать",
"map": "Карта",
"tree": "Дерево языков"
},
"charts": {
"accessByBand": "Доступ на родном языке по уровню интернета в стране",
"aria": "Диаграммы",
@ -149,11 +154,46 @@
"grid": "Группы",
"map": "Карта"
},
"res": {
"aria": "Ресурсы Евангелия для языков в выборке",
"count": "Ресурсов: {n} на {langs} из {all} языков в выборке",
"empty": "Для языков в выборке ничего не найдено.",
"groups": "Групп здесь",
"note": "Язык без строки здесь — в проверенных источниках ничего нет.",
"resource": "Ресурс",
"scope": "Объём",
"source": "Источник",
"type": "Тип"
},
"strength": {
"moderate": "средний",
"strong": "сильный"
},
"table": {
"aria": "Таблица",
"groups": "Народы",
"resources": "Ресурсы Евангелия"
},
"title": "Доступ к Евангелию через интернет",
"tree": {
"aria": "Дерево языков: каждая языковая семья этих народов, окрашенная по тому, что есть на каждом языке",
"branch": "Ветвь",
"dialect": "Диалект",
"family": "Семья",
"groupsN": "Народов: {n}",
"inherited": "Цвет его языка: ни один источник не учитывает ресурсы по диалектам.",
"language": "Язык",
"languagesHere": "языков здесь",
"legendTitle": "На родном языке",
"levels": "Семья › ветвь › язык › диалект. Семья или ветвь показывает худший цвет внутри. Диалект показывает цвет своего языка, светлее: ресурсы учитываются по языкам, а не по диалектам. Нажмите на круг, чтобы сузить панель до него.",
"sources": "Дерево: Glottolog (CC BY 4.0). Ресурсы: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, фильм «Иисус», Global Recordings и другие, по ISO 639-3.",
"st": {
"g": "Найден Новый Завет или вся Библия",
"r": "Ничего не найдено",
"y": "Что-то найдено, но Нового Завета нет"
},
"via": "Также на этом пути:"
},
"view": {
"aria": "Вид",
"board": "Панель",

View file

@ -113,7 +113,13 @@
"colCountry": "Страна",
"colLanguage": "Язык",
"colPopulation": "Численность",
"colReligion": "Религия"
"colReligion": "Религия",
"colPctChristian": "% христиан",
"colAdjacency": "Соседство",
"colCode": "Код",
"sortMobilization": "Мобилизация",
"sortFrontier": "Сначала рубежи",
"colName": "Название"
},
"fc": {
"clusterBy": "Группировать по",
@ -124,7 +130,17 @@
"legendPetal": "Охваченный сосед (детально, реальные координаты)",
"legendHelp": "нажмите верхний круг, чтобы перегруппировать · нажмите ядро, чтобы раскрыть лепестки",
"clustering": "Группировка жатвы по языку…",
"signLanguage": "Жестовый язык"
"signLanguage": "Жестовый язык",
"mode": {
"family": "Семья",
"language": "Язык",
"dialect": "Диалект"
},
"size": {
"fewerBigger": "меньше · крупнее",
"more": "больше",
"most": "больше всего"
}
},
"mc": {
"greenEncoding": "Кодирование зелёного цвета",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "Вид карты или цветка",
"loading": "Загрузка поля жатвы…",
"unreachedTarget": "недостигнутая цель"
"unreachedTarget": "недостигнутая цель",
"map": "Карта",
"flower": "Цветок",
"details": "Подробности",
"senders": "отправителей →",
"clustersHidden": "кластеров · скрыто: {n}",
"legendSend": "SEND (сплошная = тот же язык · пунктир = кластер · точки = внутри страны)",
"legendSelection": "выбор · радиус = логарифм населения · Луки 10:2",
"navySend": "тёмно-синий = SEND",
"goldMesh": "золотой = сеть одного языка"
},
"flower": {
"pickTarget": "Выберите красную цель на карте —",

View file

@ -32,5 +32,61 @@
"readyPins": "Готово · меток: {pins} · нажмите на метку",
"readyLanguage": "Готово · меток: {pins} · покрытие семей {coverage}% · топ-25 = {top}%",
"readyReligion": "Меток: {pins} · чаще всего: {religion} ({count})"
},
"ui": {
"chooseView": "Выберите вид",
"search": "Поиск по народу, месту, языку, религии…",
"baseMap": "Базовая карта",
"dataSources": "Источники данных",
"legend": "Легенда",
"openLegend": "Открыть легенду",
"clearSelectionEsc": "Снять выделение (Esc)",
"closeEsc": "Закрыть (Esc)",
"close": "Закрыть",
"retry": "Повторить",
"loadingMap": "Загрузка карты",
"flyToPin": "Перейти к метке",
"primaryReligion": "Основная религия",
"ropHierarchy": "Иерархия ROP"
},
"pill": {
"religion": "Распределение религий · нажмите на метку, чтобы узнать подробности",
"ropTree": "Иерархия ROP · блок родства → кластер → народ → PGIC",
"languageFamilies": "Этнолингвистические семьи (PPLR) · нажмите на метку, чтобы узнать подробности",
"sunburst": "Иерархия ROP · блок родства → кластер народов · прокрутите для масштаба",
"pins": "Все метки «народ в стране» · нажмите, чтобы увидеть идентификаторы систем",
"coverage": "Покрытие переводов · заливка = среднее число заполненных языков по стране · нажмите на метку или страну",
"rosetta": "Розетта идентификаторов · цвет = какие идентификаторы есть · ★ = пример"
},
"legendTitle": {
"avgLocales": "Среднее число языков / {n}",
"idCoverage": "Покрытие идентификаторов"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD, все три",
"imbOnly": "Только IMB",
"jpOnly": "Только JP",
"neither": "ни того, ни другого"
},
"samplePg": "★ пример народа",
"missingIds": "Ещё не сопоставлено в PPLR: {ids}. Пустая строка — пробел в данных, а не ошибка."
},
"popup": {
"noName": "(без названия)",
"country": "Страна",
"religion": "Религия",
"language": "Язык",
"family": "Семья",
"affinityBloc": "Блок родства",
"cluster": "Кластер",
"peopleCluster": "Кластер народов",
"peopleGroup": "Народ",
"pgCount": "Народы",
"avgLocalesFilled": "Среднее число заполненных языков",
"iso3Country": "Страна ISO-3"
},
"card": {
"nameInLocales": "Название на {n} языках"
}
}

View file

@ -131,7 +131,8 @@
"t_deaf": "聋人缺口",
"t_resources": "按族群列出资源",
"t_languages": "各国语言",
"t_growth": "可及性与人口增长"
"t_growth": "可及性与人口增长",
"t_bridgereach": "一种语言,覆盖多少民族"
},
"pages": {
"listTitle": "按语言列出的福音资源",

View file

@ -5,6 +5,11 @@
"partial": "上网率 25–59%",
"yes": "上网率 60% 或以上"
},
"centre": {
"aria": "显示",
"map": "地图",
"tree": "语言树"
},
"charts": {
"accessByBand": "母语资源情况,按国家互联网使用率",
"aria": "图表",
@ -149,11 +154,46 @@
"grid": "族群",
"map": "地图"
},
"res": {
"aria": "当前范围内各语言的福音资源",
"count": "当前范围 {all} 种语言中,有 {langs} 种共 {n} 项资源",
"empty": "当前范围内的语言未找到任何资源。",
"groups": "此处族群",
"note": "此处没有行的语言,在已查阅的来源中一无所有。",
"resource": "资源",
"scope": "范围",
"source": "来源",
"type": "类型"
},
"strength": {
"moderate": "中等",
"strong": "强"
},
"table": {
"aria": "表格",
"groups": "族群",
"resources": "福音资源"
},
"title": "通过互联网接触福音",
"tree": {
"aria": "语言树:这些族群的每个语系,按各语言中已找到的资源着色",
"branch": "分支",
"dialect": "方言",
"family": "语系",
"groupsN": "{n} 个族群",
"inherited": "其语言的颜色:没有任何来源按方言编目资源。",
"language": "语言",
"languagesHere": "种语言",
"legendTitle": "以他们的母语",
"levels": "语系 › 分支 › 语言 › 方言。语系或分支显示其内部最差的颜色。方言显示其语言的颜色(较浅):资源按语言编目,而不是按方言。点击圆圈即可将看板缩小到该范围。",
"sources": "语言树:Glottolog(CC BY 4.0)。资源:eBible、Faith Comes By Hearing、Scripture Earth、PNG.Bible、耶稣传影片、Global Recordings 等,按 ISO 639-3 编目。",
"st": {
"g": "已找到新约或整本圣经",
"r": "未找到",
"y": "找到一些资源,但没有新约"
},
"via": "此路径上还有:"
},
"view": {
"aria": "视图",
"board": "看板",

View file

@ -113,7 +113,13 @@
"colCountry": "国家",
"colLanguage": "语言",
"colPopulation": "人口",
"colReligion": "宗教"
"colReligion": "宗教",
"colPctChristian": "基督徒 %",
"colAdjacency": "邻近关系",
"colCode": "代码",
"sortMobilization": "动员",
"sortFrontier": "前沿优先",
"colName": "名称"
},
"fc": {
"clusterBy": "分组依据",
@ -124,7 +130,17 @@
"legendPetal": "已得邻居(细分,真实坐标)",
"legendHelp": "点击上方圆圈重新分组 · 点击核心展开花瓣",
"clustering": "正在按语言整理庄稼…",
"signLanguage": "手语"
"signLanguage": "手语",
"mode": {
"family": "语系",
"language": "语言",
"dialect": "方言"
},
"size": {
"fewerBigger": "更少 · 更大",
"more": "更多",
"most": "最多"
}
},
"mc": {
"greenEncoding": "绿色编码",
@ -138,7 +154,16 @@
"cockpit": {
"viewSwitch": "地图或花朵视图",
"loading": "正在加载庄稼之田…",
"unreachedTarget": "未得目标"
"unreachedTarget": "未得目标",
"map": "地图",
"flower": "花朵",
"details": "详情",
"senders": "派遣方 →",
"clustersHidden": "个民族群 · 隐藏 {n} 个",
"legendSend": "SEND(实线 = 同语言 · 虚线 = 民族群 · 点线 = 国内)",
"legendSelection": "选中 · 半径 = 人口对数 · 路加福音 10:2",
"navySend": "深蓝 = SEND",
"goldMesh": "金色 = 同语言网络"
},
"flower": {
"pickTarget": "在地图上选择一个红色目标 —",

View file

@ -32,5 +32,61 @@
"readyPins": "就绪 · 族群标记:{pins} · 点击任一标记",
"readyLanguage": "就绪 · 标记:{pins} · 语系覆盖率 {coverage}% · 前 25 = {top}%",
"readyReligion": "标记:{pins} · 最常见:{religion}({count})"
},
"ui": {
"chooseView": "选择视图",
"search": "按族群、地点、语言、宗教搜索…",
"baseMap": "底图",
"dataSources": "数据来源",
"legend": "图例",
"openLegend": "打开图例",
"clearSelectionEsc": "清除选择 (Esc)",
"closeEsc": "关闭 (Esc)",
"close": "关闭",
"retry": "重试",
"loadingMap": "正在加载地图",
"flyToPin": "飞到标记",
"primaryReligion": "主要宗教",
"ropHierarchy": "ROP 层级"
},
"pill": {
"religion": "宗教分布 · 点击标记查看详情",
"ropTree": "ROP 层级 · 亲缘区块 → 民族群 → 族群 → PGIC",
"languageFamilies": "民族语言语系 (PPLR) · 点击标记查看详情",
"sunburst": "ROP 层级 · 亲缘区块 → 民族群 · 滚动缩放",
"pins": "全部国家内族群标记 · 点击查看跨系统 ID",
"coverage": "翻译覆盖率 · 着色 = 各国平均已填语种数 · 点击标记或国家",
"rosetta": "跨系统 ID 对照 · 颜色 = 已有哪些 ID · ★ = 示例行"
},
"legendTitle": {
"avgLocales": "平均语种数 / {n}",
"idCoverage": "ID 覆盖"
},
"rosetta": {
"legend": {
"all": "JP + IMB + WCD 三者俱全",
"imbOnly": "仅 IMB",
"jpOnly": "仅 JP",
"neither": "两者皆无"
},
"samplePg": "★ 示例族群",
"missingIds": "PPLR 尚未对应:{ids}。空行是数据缺口,不是错误。"
},
"popup": {
"noName": "(无名称)",
"country": "国家",
"religion": "宗教",
"language": "语言",
"family": "语系",
"affinityBloc": "亲缘区块",
"cluster": "民族群",
"peopleCluster": "民族群",
"peopleGroup": "族群",
"pgCount": "族群数",
"avgLocalesFilled": "平均已填语种数",
"iso3Country": "ISO-3 国家"
},
"card": {
"nameInLocales": "{n} 种语言中的名称"
}
}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* build-web-boundaries.mjs — the INTERACTIVE-map copies of the boundary files: small enough to parse
* fast, with every feature, every island and every property kept.
*
* node library/scripts/boundaries/build-web-boundaries.mjs
*
* WHY. rega measured ~3.6 MB of boundary GeoJSON (world-adm0 1.9 MB, gb-IND-ADM1 1.6 MB) costing
* 5–6 s of MapLibre worker parsing before the first territory draws, on every page of every
* partner site. A web map drawn at zoom 1–7 needs a fraction of those vertices. The FULL files stay
* as they are: posters and print use them, and so does every build-time point-in-polygon.
*
* HOW (mapshaper, pinned below; fetched with npx, it is not a repo dependency):
* -explode split each multi-part feature into its parts, so every island is its own shape
* -simplify visvalingam weighted percentage=P keep-shapes
* topology-preserving (shared borders stay shared); keep-shapes stops any shape
* from vanishing, which, after the explode, means no island is ever dropped
* (without the explode, keep-shapes guards only whole features: 572 parts -> 240)
* -dissolve KEY copy-fields=... put each feature back together, same properties
* precision=0.0001 ~11 m, far below a pixel at the zooms these files are drawn at
*
* VALIDATED, the run FAILS otherwise: the same feature count, the same keys, the same polygon-part
* count (no dropped island), and the same property names and values on every feature.
* The measured report (size, parts, area error, point-in-polygon drift) is in
* _qa-cycle/03-deployment-cycle/REPORT-WEB-BOUNDARIES.md.
*/
import { execFileSync } from 'node:child_process'
import { readFileSync, statSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const MAPSHAPER = 'mapshaper@0.6.121'
const UPG = 'plugin-designs/my-upg-100-list/data'
const JOBS = [
// src, out, dissolve key, the other properties, simplification percentage
{ src: `${UPG}/adm0/world-adm0.geojson`, out: `${UPG}/adm0/world-adm0-web.geojson`, key: 'iso3', copy: ['shapeGroup', 'shapeName'], pct: '8%' },
{ src: 'data/boundaries/gb-IND-ADM1-full.geojson', out: 'data/boundaries/gb-IND-ADM1-full-web.geojson', key: 'shapeName', copy: ['iso3', 'shapeGroup', 'shapeISO'], pct: '10%' },
// UPG-100's curated partition: `listId` is load-bearing (zone-geometry.js), so it must survive.
{ src: `${UPG}/gb-IND-ADM1.geojson`, out: `${UPG}/gb-IND-ADM1-web.geojson`, key: 'shapeName', copy: ['iso3', 'shapeGroup', 'listId'], pct: '10%' },
// gb-CHN-ADM1.geojson is 56 KB (12 provinces): already web-sized, no copy made.
]
const fail = (m) => { console.error('build-web-boundaries: FAIL — ' + m); process.exit(1) }
const parts = (fc) => fc.features.reduce((t, f) => t + (f.geometry.type === 'Polygon' ? 1 : f.geometry.coordinates.length), 0)
for (const j of JOBS) {
const src = join(ROOT, j.src), out = join(ROOT, j.out)
execFileSync('npx', ['--yes', MAPSHAPER, src, '-explode',
'-simplify', 'visvalingam', 'weighted', `percentage=${j.pct}`, 'keep-shapes',
'-dissolve', j.key, `copy-fields=${j.copy.join(',')}`,
'-o', out, 'format=geojson', 'precision=0.0001'], { stdio: ['ignore', 'ignore', 'pipe'] })
const a = JSON.parse(readFileSync(src, 'utf8')), b = JSON.parse(readFileSync(out, 'utf8'))
if (a.features.length !== b.features.length) fail(`${j.out}: ${b.features.length} features, source has ${a.features.length}`)
const byKey = new Map(b.features.map((f) => [f.properties[j.key], f]))
for (const f of a.features) {
const g = byKey.get(f.properties[j.key])
if (!g) fail(`${j.out}: lost ${j.key}=${f.properties[j.key]}`)
for (const [k, v] of Object.entries(f.properties)) if (g.properties[k] !== v) fail(`${j.out}: ${f.properties[j.key]}.${k} is ${JSON.stringify(g.properties[k])}, source ${JSON.stringify(v)}`)
}
if (parts(a) !== parts(b)) fail(`${j.out}: ${parts(b)} polygon parts, source has ${parts(a)} (an island was dropped)`)
const kb = (p) => Math.round(statSync(p).size / 1024)
console.log(`${j.out}: ${kb(src)} KB -> ${kb(out)} KB · ${b.features.length} features · ${parts(b)} parts · properties identical`)
}

View file

@ -182,7 +182,9 @@
"funnel_list_unreached": "Project 7000: unreached people groups",
"funnel_list_frontier": "Project 4700: frontier people groups",
"funnel_list_frontier-500k": "Project 700: frontier people groups of 500,000 or more",
"funnel_list_unengaged": "Unengaged unreached people groups"
"funnel_list_unengaged": "Unengaged unreached people groups",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} more"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
@ -357,7 +359,9 @@
"funnel_list_unreached": "Proyecto 7000: grupos étnicos no alcanzados",
"funnel_list_frontier": "Proyecto 4700: grupos étnicos de frontera",
"funnel_list_frontier-500k": "Proyecto 700: grupos étnicos de frontera de 500.000 o más",
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros"
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} más"
},
"fr": {
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
@ -532,7 +536,9 @@
"funnel_list_unreached": "Projet 7000 : peuples non atteints",
"funnel_list_frontier": "Projet 4700 : peuples frontières",
"funnel_list_frontier-500k": "Projet 700 : peuples frontières de 500 000 personnes ou plus",
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier"
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} autres"
},
"pt": {
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
@ -707,7 +713,9 @@
"funnel_list_unreached": "Projeto 7000: grupos étnicos não alcançados",
"funnel_list_frontier": "Projeto 4700: grupos étnicos de fronteira",
"funnel_list_frontier-500k": "Projeto 700: grupos étnicos de fronteira de 500.000 ou mais",
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros"
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} mais"
},
"de": {
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
@ -882,7 +890,9 @@
"funnel_list_unreached": "Projekt 7000: unerreichte Volksgruppen",
"funnel_list_frontier": "Projekt 4700: Frontier-Volksgruppen",
"funnel_list_frontier-500k": "Projekt 700: Frontier-Volksgruppen mit 500.000 oder mehr",
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter"
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} weitere"
},
"it": {
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
@ -1057,7 +1067,9 @@
"funnel_list_unreached": "Progetto 7000: gruppi etnici non raggiunti",
"funnel_list_frontier": "Progetto 4700: gruppi etnici di frontiera",
"funnel_list_frontier-500k": "Progetto 700: gruppi etnici di frontiera di 500.000 o più",
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai"
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + altri {n}"
},
"ro": {
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
@ -1232,7 +1244,9 @@
"funnel_list_unreached": "Proiectul 7000: grupuri etnice neatinse",
"funnel_list_frontier": "Proiectul 4700: grupuri etnice de frontieră",
"funnel_list_frontier-500k": "Proiectul 700: grupuri etnice de frontieră de 500.000 sau mai multe",
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători"
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + încă {n}"
},
"ru": {
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
@ -1407,7 +1421,9 @@
"funnel_list_unreached": "Проект 7000: неохваченные народы",
"funnel_list_frontier": "Проект 4700: народы-фронтир",
"funnel_list_frontier-500k": "Проект 700: народы-фронтир численностью 500 000 и более",
"funnel_list_unengaged": "Неохваченные народы без работников"
"funnel_list_unengaged": "Неохваченные народы без работников",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + ещё {n}"
},
"ar": {
"map_page_kicker": "شعوب غير مبلَّغة",
@ -1582,7 +1598,9 @@
"funnel_list_unreached": "مشروع 7000: المجموعات الشعبية غير المبلَّغة",
"funnel_list_frontier": "مشروع 4700: المجموعات الشعبية الحدودية",
"funnel_list_frontier-500k": "مشروع 700: المجموعات الشعبية الحدودية التي يبلغ عددها 500,000 أو أكثر",
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين"
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} أخرى"
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
@ -1757,7 +1775,9 @@
"funnel_list_unreached": "परियोजना 7000: अप्राप्त जनसमूह",
"funnel_list_frontier": "परियोजना 4700: सीमांत जनसमूह",
"funnel_list_frontier-500k": "परियोजना 700: 500,000 या अधिक के सीमांत जनसमूह",
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं"
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + {n} और"
},
"zh": {
"map_page_kicker": "未得之民",
@ -1932,6 +1952,8 @@
"funnel_list_unreached": "7000 计划:未得之民族群",
"funnel_list_frontier": "4700 计划:前沿族群",
"funnel_list_frontier-500k": "700 计划:50 万人以上的前沿族群",
"funnel_list_unengaged": "无人服事的未得之民族群"
"funnel_list_unengaged": "无人服事的未得之民族群",
"zone_join": "{a} + {b}",
"zone_join_more": "{a} + 另外 {n} 个"
}
}
}

View file

@ -0,0 +1,26 @@
// app-kind.mjs — what KIND of communication channel an App Store app is, from its name.
//
// A NAME-AND-KEYWORD HEURISTIC over a known list, recorded as such in every table that uses it.
// It lives in its own file so the fetcher (fetch-top-apps.mjs) and the table builder
// (build-tabulator-tables.mjs) classify with the SAME rule: the builder re-derives the kind from
// the stored name, so a cached fetch made with an older rule cannot carry an old answer forward.
//
// ★ A BRAND THAT IS ALSO AN ENGLISH WORD MUST BE ANCHORED. `line` matched as a bare word made
// "LINE MAN: Food Delivery & more" (Thailand) and "Malé Taxi Line" (Maldives) each country's TOP
// messaging app, ahead of WhatsApp. LINE's own app is "LINE" or "LINE: Calls & Messages", so it
// matches only as the whole name or the name before a colon.
export const KIND = [
[/\b(whatsapp|telegram|signal|viber|imo|zalo|wechat|weixin|kakaotalk|botim|imessage|messenger|skype|hike|jio ?chat|soul|beetalk)\b|^line(?:$|\s*:)/i, 'messaging'],
[/\b(tiktok|douyin|kwai|likee|snack ?video|moj|josh|triller|reels)\b/i, 'short-video'],
[/\b(facebook|instagram|snapchat|x|twitter|threads|vk|odnoklassniki|weibo|qzone|xiaohongshu|little red|sharechat|koo|mastodon|bluesky)\b/i, 'social'],
[/\b(youtube|bigo|nimo|twitch|rumble|dailymotion|vimeo)\b/i, 'video'],
[/\b(opera|uc ?browser|chrome|firefox|brave|puffin|phoenix ?browser)\b/i, 'browser'],
];
export function classify(name) {
for (const [re, k] of KIND) if (re.test(String(name || '').trim())) return k;
return 'other';
}
// The kinds a reachability table keeps (everything else is not a communication channel).
export const isComms = (kind) => kind !== 'other' && kind !== 'browser';

View file

@ -28,6 +28,7 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { classify, isComms } from './app-kind.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, 'out');
@ -553,6 +554,15 @@ if (has('social-platforms-by-country.json')) {
}
}
// The headline counts are COUNTED from the rows, never typed: a typed "54" stayed true only until
// the classifier changed. WhatsApp is its two apps (Messenger + Business) together.
function messagingWhy(rows) {
const top = new Map();
for (const r of rows) if (r.is_top_messaging) top.set(r.iso3, r.app);
const count = (re) => [...top.values()].filter((a) => re.test(a)).length;
return `Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in ${count(/whatsapp/i)} countries, Telegram in ${count(/telegram/i)} — neither appears in referral share at all.`;
}
// ═══ TABLE 7 — MESSAGING & APP CHANNELS (what referral stats cannot see) ═══
// StatCounter's ENTIRE tracked universe is Western referral platforms — measured:
// Facebook/Instagram/Twitter/YouTube/Pinterest/reddit/LinkedIn/Tumblr/HN/VK/Fark.
@ -572,7 +582,11 @@ if (has('top-apps-by-country.json')) {
for (const c of Object.values(apps.countries)) {
const meta = g[c.iso3] || {};
// One row per COUNTRY x APP so a list view can sort "where does Telegram rank".
for (const a of (c.comms_apps || [])) {
// The kind is RE-DERIVED from the name with the shared classifier (app-kind.mjs), and the top
// messaging app with it: the cached fetch may carry a kind from an older rule.
const comms = (c.comms_apps || []).map((a) => ({ ...a, kind: classify(a.name) })).filter((a) => isComms(a.kind));
const topMessaging = comms.slice().sort((x, y) => x.rank - y.rank).find((a) => a.kind === 'messaging')?.name || null;
for (const a of comms) {
rows.push({
country: meta.country || c.iso3,
iso3: c.iso3,
@ -580,7 +594,7 @@ if (has('top-apps-by-country.json')) {
app: a.name,
kind: a.kind,
rank: a.rank,
is_top_messaging: a.name === c.top_messaging,
is_top_messaging: a.name === topMessaging,
people_groups: meta.groups || 0,
platform_bias: c.platform_bias,
classification: c.classification_method,
@ -605,7 +619,7 @@ if (has('top-apps-by-country.json')) {
], {
title: 'Messaging & App Channels',
grain: 'one row per country x communication app',
why: 'Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.',
why: messagingWhy(rows),
ios_caveat: 'iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.',
ranking_caveat: 'A top-free RANK is most-DOWNLOADED recently, not most-used.',
classification_caveat: 'kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.',

View file

@ -40,6 +40,7 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { classify, isComms } from './app-kind.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, 'out');
@ -55,19 +56,7 @@ const SOURCE = {
why: 'StatCounter tracks only Western referral platforms and cannot see WhatsApp/WeChat/Telegram/TikTok/LINE/Viber/imo/Zalo — the channels that dominate most of this corpus',
};
// Known non-Western + messaging platforms worth surfacing explicitly. Matching is
// a HEURISTIC on the app name; recorded as such.
const KIND = [
[/\b(whatsapp|telegram|signal|viber|imo|line|zalo|wechat|weixin|kakaotalk|botim|imessage|messenger|skype|hike|jio ?chat|soul|beetalk)\b/i, 'messaging'],
[/\b(tiktok|douyin|kwai|likee|snack ?video|moj|josh|triller|reels)\b/i, 'short-video'],
[/\b(facebook|instagram|snapchat|x|twitter|threads|vk|odnoklassniki|weibo|qzone|xiaohongshu|little red|sharechat|koo|mastodon|bluesky)\b/i, 'social'],
[/\b(youtube|bigo|nimo|twitch|rumble|dailymotion|vimeo)\b/i, 'video'],
[/\b(opera|uc ?browser|chrome|firefox|brave|puffin|phoenix ?browser)\b/i, 'browser'],
];
const classify = (name) => {
for (const [re, k] of KIND) if (re.test(name)) return k;
return 'other';
};
// The classifier (KIND / classify) is shared with build-tabulator-tables.mjs: see app-kind.mjs.
const ISO2 = existsSync(join(OUT, 'iso3-to-iso2.json'))
? JSON.parse(readFileSync(join(OUT, 'iso3-to-iso2.json'), 'utf8'))
@ -113,7 +102,7 @@ for (let n = 0; n < todo.length; n++) {
}
} catch (e) { err = String(e.message || e).slice(0, 60); }
const comms = apps.filter((a) => a.kind !== 'other' && a.kind !== 'browser');
const comms = apps.filter((a) => isComms(a.kind));
out[iso3] = {
iso3, iso2: ISO2[iso3],
apps: apps.slice(0, 25),

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

View file

@ -107,6 +107,7 @@
"check:i18n-keys-exist": "node plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs",
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:i18n-stopped-scope": "node internal/check-i18n-stopped-scope.mjs",
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
"check:kit-options-reachable": "node plugin-designs/research-dashboard/scripts/check-kit-options-reachable.mjs",
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
@ -233,7 +234,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:regions-uses-library check:shadow-css-parses",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
@ -340,6 +341,7 @@
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
"check:i18n-stopped-scope": "createAppI18n() must succeed while a STOPPED effect scope is current: the Driver's P0 (Plugin Studio gallery, every tab after the first threw 'SyntaxError: Unexpected error' from createI18n, because Vue 3.5 makes a new scope inactive under a stopped one). A bare createI18n() in the same scope must throw first, or the harness proves nothing. Also asserts a bundle's second provide('appI18n') raises no warning.",
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
"check:kit-options-reachable": "every option a shared kit composable ACCEPTS must be honoured somewhere in its body. Written because I nearly shipped one: kit landing 3 gave useSelectionCamera a `coords` option so a dashboard whose rows spell their coordinate differently could say so, and it was accepted in the signature and never passed to selectionBounds - a caller could set it, type-check clean, build clean and watch it do nothing. That is the worst shape a missing feature takes, because it READS as supported and nobody files a bug against a knob they assume they misused; this tree already paid for it once with a defineProps on a component the custom element never binds. It matters more for the kit than for one dashboard: the whole promise of SPEC-DASHBOARD-KIT is that a config produces a working dashboard, and a parameter that is merely present turns that promise into a guess. Strips comments from BOTH the signature and the body before searching - the first version was satisfied by the comment beside the fixed call site, and the second read comment prose as option names. Not a type checker: it catches a completely unused option, which is the failure invisible from outside.",
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",

View file

@ -239,7 +239,7 @@ async function load() {
// review caught it as "India seems to have no internet coverage". The fix was a
// second file, world-adm0-plus.geojson; that file is gone and India is fetched
// at ADM0 like every other country (data/source-boundaries.mjs).
fetch(`${assetsBase.value}/boundaries/world-adm0.geojson`).then((r) => r.json()),
fetch(`${assetsBase.value}/boundaries/world-adm0-web.geojson`).then((r) => r.json()),
])
rows.value = data.countries || []
return adm0
@ -304,7 +304,7 @@ async function loadAdm1() {
fetch(`${assetsBase.value}/tables/internet-by-adm1.json`).then((r) => { if (!r.ok) throw new Error('internet-by-adm1 ' + r.status); return r.json() }),
// All 36 states (data/boundaries/gb-IND-ADM1-full.geojson). NOT gb-IND-ADM1.geojson: that one is
// UPG-100's partition, curated to 34 units, and it left Mizoram and Nagaland with no geometry.
fetch(`${assetsBase.value}/boundaries/gb-IND-ADM1-full.geojson`).then((r) => { if (!r.ok) throw new Error('gb-IND-ADM1-full ' + r.status); return r.json() }),
fetch(`${assetsBase.value}/boundaries/gb-IND-ADM1-full-web.geojson`).then((r) => { if (!r.ok) throw new Error('gb-IND-ADM1-full ' + r.status); return r.json() }),
])
const byArea = {}
for (const r of table.rows || []) {

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

View file

@ -1114,7 +1114,7 @@ async function load() {
// review caught it as "India seems to have no internet coverage". The fix was a
// second file, world-adm0-plus.geojson; that file is gone and India is fetched
// at ADM0 like every other country (data/source-boundaries.mjs).
fetch(`${assetsBase.value}/boundaries/world-adm0.geojson`).then((r) => r.json()),
fetch(`${assetsBase.value}/boundaries/world-adm0-web.geojson`).then((r) => r.json()),
// 650KB and it is the point of the map, so it loads with the boundaries rather
// than behind a tap. URLs are normalised into `codes` by ISO 639-3, so the 12,379
// pins carry a code rather than 12,379 copies of the same jesusfilm.org link.
@ -1270,7 +1270,7 @@ async function ensureProvinceTable() {
return provinceRows.value
}
const PROVINCE_FILE = { IND: 'gb-IND-ADM1-full.geojson', CHN: 'gb-CHN-ADM1.geojson' }
const PROVINCE_FILE = { IND: 'gb-IND-ADM1-full-web.geojson', CHN: 'gb-CHN-ADM1.geojson' }
async function ensureProvinces(iso3) {
const map = mapRef.value

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -85,6 +85,16 @@ function leafPopulation(row, records) {
export function buildLegendNodes(clusters, opts = {}) {
const deafCount = opts.deafCount ?? 123;
const records = Array.isArray(opts.records) ? opts.records : null;
// ★ OPTIONAL GENERATED TITLE (Driver, 2026-09-27). `opts.title(zone)` returns a localized title
// composed from already-translated parts, or NULL for a zone whose title cannot be generated — see
// data/zone-title.js. Passing nothing keeps every hand-written English label exactly as it was, which
// is why this is an option rather than a rewrite: the legend, the poster and the packet adopt it
// independently, and a caller that has no records yet (records:null) still builds a correct tree.
// ⚠ NULL MEANS "KEEP YOURS", NOT "NO TITLE". 18 of 28 zones decline (their labels are sub-national or
// taxonomic, and Intl has no subdivisions), so the fallback is the normal path, not the error path.
const titleOf = typeof opts.title === 'function'
? (zone, fallback) => opts.title(zone) || fallback
: (_zone, fallback) => fallback;
const lists = clusters.lists || [];
const asia = clusters.asia_lists || [];
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
@ -137,7 +147,7 @@ export function buildLegendNodes(clusters, opts = {}) {
{
id: l.id,
zoneKey: l.id,
label: l.label,
label: titleOf(l, l.label),
// Carry the province enumeration the region-name label replaced.
...(l.provinceLabel ? { provinceLabel: l.provinceLabel } : {}),
color: colorOf(l.id),
@ -171,7 +181,7 @@ export function buildLegendNodes(clusters, opts = {}) {
{
id: r.id,
zoneKey: r.id,
label: r.label,
label: titleOf(r, r.label),
// The province enumeration the region-name label replaced. Carried through
// like adm0/provinces above — a node rebuilt field-by-field silently drops
// anything not named, which is exactly how adm0 and population were lost.
@ -228,7 +238,7 @@ export function buildLegendNodes(clusters, opts = {}) {
{
id: u.id,
zoneKey: u.id,
label: u.label,
label: titleOf(u, u.label),
// Carry the province enumeration the region-name label replaced.
...(u.provinceLabel ? { provinceLabel: u.provinceLabel } : {}),
color: colorOf(u.id),

View file

@ -0,0 +1,178 @@
/**
* zone-title — compose a zone's DISPLAY TITLE from parts that are ALREADY translated, so adding a
* project list costs no new translation and adding a locale costs no code change.
*
* THE PROBLEM (Driver, 2026-09-27). Every zone label is a hand-written English string — "Middle East
* Core", "Malabar Coast" — so the Arabic map reads English. Hand-translating them does not scale,
* because UPG-100 is becoming a PATTERN: Project 7000 / 4700 / 3000 / 2000 / 700 each invent their own
* ~100 clusters, and a per-title translation is a cost paid again for every list, forever. The Driver's
* question was precisely "how do you generate a title without a new translation each time".
*
* ★ WHAT THE DATA ACTUALLY ALLOWS, measured before any of this was written (2026-09-27):
*
* SOURCE COVERAGE OF OUR DATA FAILURE MODE
* country_code_label from the records API all 175 codes, every locale (absent field)
* Intl.DisplayNames({type:'region'}) all 175, every locale THROWS on ISO-3
* Intl.DisplayNames({type:'language'}) 213 of 2242 = 9.5% ECHOES THE CODE
*
* ⚠ LANGUAGE IS NOT A USABLE TITLE SOURCE, which kills the more attractive half of the original idea.
* `new Intl.DisplayNames(['en'],{type:'language'}).of('kfa')` returns the STRING "kfa" — it does not
* throw — so a miss prints a raw code onto a printed poster with nothing raising a hand. And the 90.5%
* miss is STRUCTURAL rather than a gap someone can fill: unreached peoples speak small languages by
* definition, and CLDR carries the big ones. So this module never names a language.
*
* ★ AND COUNTRY NAMES COME FROM THE RECORDS, NOT FROM Intl, which was the measurement that simplified
* the whole design. The records API already returns `country_code_label` localized by its own `lang`
* parameter — verified live: IND is الهند / भारत / 印度 / Индия for lang=ar/hi/zh/ru, matching Intl
* exactly. Since the packet and the page ALREADY fetch these records at the reader's locale, the
* localized name is free and needs no ISO-3166 alpha-3 → alpha-2 mapping at all. Intl is kept only as a
* FALLBACK for a caller who has a code and no record.
*
* WHICH ZONES THIS CAN AND CANNOT TITLE — stated here because a generator that silently produces a bad
* title is worse than one that declines:
* TIER A selector country_code → one country name. GENERATED
* TIER B selector country_codes → 2..n country names + a joiner. GENERATED
* TIER C wagf_block / wagf_region / pg_slugs / union / affinity_bloc → null. DECLINED
* Tier C is 18 of our 28 zones and it cannot be done from country names: the 12 India/China zones are
* `pg_slugs` whose every record is ONE country (measured: asia-india-kerala is 78/78 IND), so countries
* would render "India" twelve times and could not tell "Malabar Coast" from "Northwest India" — those
* labels are SUB-NATIONAL. Naming them needs a province/subdivision source, which Intl has none of; the
* curated-table-vs-keep-English choice is with the Driver (repo's digest item D). Until it is decided
* this returns null for tier C and the caller keeps the existing label, so nothing regresses.
*
* THE ONLY TRANSLATED STRINGS ARE THE TWO JOINERS — `zone_join` ("{a} + {b}") and `zone_join_more`
* ("{a} + {n} more") — in packet-strings.json, one pair per locale block. That is the entire
* translation cost for every project list that ever uses this.
*/
/** Tier A/B selector types. Anything else is tier C and declined. */
const COUNTRY_SELECTORS = new Set(['country_code', 'country_codes'])
/**
* The ISO-3 codes a selector names, in the order the selector lists them.
* ⚠ ORDER IS THE AUTHOR'S, NOT ALPHABETICAL. clusters.json lists the countries of a zone with the
* biggest/most representative first (middle-east-core starts IRN,YEM,IRQ), and that order is editorial
* information: it is what makes "Iran + Yemen + 8 more" a better title than "Bahrain + Iran + 8 more".
* Sorting here would discard a judgement someone already made.
*/
export function zoneCountryCodes(selector) {
if (!selector || !COUNTRY_SELECTORS.has(selector.type)) return []
if (selector.type === 'country_code') return selector.value ? [selector.value] : []
return Array.isArray(selector.values) ? selector.values.filter(Boolean) : []
}
/**
* ISO-3 → a localized country name.
*
* @param {string} iso3
* @param {object} opts
* @param {Map<string,string>} [opts.byCode] ISO-3 → country_code_label, built from the records this
* page/packet already loaded AT THE READER'S LOCALE. The preferred source: no alpha-2 mapping,
* no second service, and it agrees with Intl where both have an answer.
* @param {string} [opts.locale] used only by the Intl fallback.
* @param {(iso3:string, locale:string)=>string} [opts.countryName] regions-plugin's coming library
* helper (their card BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2). Injected rather than imported
* so this module does not depend on an unlanded file; when it lands, pass it and it wins over
* the local Intl fallback.
* @returns {string|null} null rather than the raw code — see below.
*/
export function countryLabel(iso3, opts = {}) {
const code = String(iso3 || '').toUpperCase()
if (!code) return null
// ★ ORDER: regions' helper (or Intl) FIRST, the records' label SECOND — and this order is the
// opposite of the one I wrote first, corrected by measuring what the two sources actually say.
// The records API returns the FORMAL country name, which is wrong for a poster header:
// LAO "Lao People's Democratic Republic" vs Intl "Laos"
// IRN "Islamic Republic of Iran" vs Intl "Iran"
// CHN "People's Republic of China" vs Intl "China"
// 28 of our 175 country labels are long formal forms like these. My first version preferred the
// records because they were already localized and needed no code mapping — true, and it produced
// "Lao People's Democratic Republic" as the title of the zone whose hand-written label was "Laos",
// i.e. a generated title WORSE than the one it replaced. Intl gives the short common form in every
// locale, which is what a header and a 380px legend row want.
// The records stay as the fallback because they need no alpha-2 mapping and are always present.
if (typeof opts.countryName === 'function') {
const n = opts.countryName(code, opts.locale || 'en')
if (n && n !== code) return n
}
// ⚠ Intl REGIONS TAKE ALPHA-2 AND THROW ON ALPHA-3 — `new Intl.DisplayNames(['en'],{type:'region'})
// .of('IND')` is a RangeError, not a miss. So a 3-letter code needs a mapping, and `alpha2` is that
// mapping, INJECTED rather than imported: regions-plugin owns the ISO-3→ISO-2 table (their card
// BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2) and it has not landed, so this module must work without
// it and improve when it arrives. With no mapper a 3-letter code simply falls through to the records
// label below — the formal name, but a real localized name.
// That THROW-vs-ECHO asymmetry with the language type is also why every lookup here ends in null
// rather than in a code: one failure is loud, the other silent, and null normalises both.
const two = code.length === 2 ? code
: (typeof opts.alpha2 === 'function' ? String(opts.alpha2(code) || '').toUpperCase() : '')
if (two.length === 2) {
try {
const n = new Intl.DisplayNames([opts.locale || 'en'], { type: 'region' }).of(two)
if (n && n !== two) return n
} catch (_) { /* not a region Intl knows */ }
}
// The records' own label: always localized, always present, but the FORMAL form. Second choice, not
// no choice.
const fromRecords = opts.byCode?.get(code)
if (fromRecords) return fromRecords
// ★ NEVER RETURN THE CODE. "SDN + 3 more" on a poster looks like a data bug to a reader and, worse,
// reads as a successful translation to a gate that only checks the string is non-empty. A null makes
// the caller fall back to the existing label, which is English but is at least a WORD.
return null
}
/**
* The generated title for a zone, or null if this zone is not the kind that can be generated.
*
* @param {object} zone a clusters.json zone (needs .selector)
* @param {object} strings the resolved locale block from packet-strings.json (per-KEY fallback to en
* already applied by the caller, as packet.mjs does)
* @param {object} opts as countryLabel, plus:
* @param {number} [opts.maxParts=2] how many countries to name before collapsing to "+ n more".
* 2 by default because these titles print on a poster header and in a legend row: three names
* already wrap at the legend's 380px panel width in ar and hi, where the words are longer.
* @returns {string|null}
*/
export function zoneTitle(zone, strings, opts = {}) {
const codes = zoneCountryCodes(zone?.selector)
if (!codes.length) return null // tier C — declined, caller keeps its label
const maxParts = Math.max(1, opts.maxParts ?? 2)
const named = codes.map((c) => countryLabel(c, opts))
// ★ ALL-OR-NOTHING ON THE NAMED HEAD. If any of the countries we would SHOW has no name, decline the
// whole title rather than printing a partial one: a title that names two of three countries and drops
// the third silently misinforms, and it cannot be distinguished from a correct title downstream.
const head = named.slice(0, maxParts)
if (head.some((n) => !n)) return null
if (codes.length === 1) return head[0]
if (codes.length <= maxParts) {
// Fold left so three names read "a + b + c" using the same two-part joiner, rather than needing a
// separate three-part string per locale.
return head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`)
}
const rest = codes.length - maxParts
const joined = head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`)
return fill(strings?.zone_join_more, { a: joined, n: rest }) ?? `${joined} + ${rest} more`
}
/** `{a}` / `{b}` / `{n}` interpolation. Returns null on a missing pattern so the caller can default. */
function fill(pattern, params) {
if (!pattern) return null
return String(pattern).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
}
/**
* ISO-3 → localized country label, from records the caller already has.
* Built once per render rather than scanned per zone: 2107 records x 28 zones is 59k scans for data
* that does not change during a render.
*/
export function countryLabelIndex(records) {
const m = new Map()
for (const r of records || []) {
const cc = r?.country_code
const label = r?.country_code_label
if (cc && label && !m.has(cc)) m.set(String(cc).toUpperCase(), label)
}
return m
}

View file

@ -612,14 +612,32 @@ useShadowStyles(
// The detail panel is the legend's mirror image (it sits top-RIGHT in LTR), so it has to swap too or
// the two would trade places and collide on the other side.
'[dir="rtl"] .upg-detail-panel{right:auto!important;left:8px!important}' +
// ⚠ AND THE ROWS THEMSELVES. dir on the wrapper does NOT reach content that sets its own direction,
// and the legend's rows render zone names + numbers that must read right-to-left as a unit. Without
// this the panel mirrors but its contents stay LTR — which is what the picture shows.
'[dir="rtl"] .stl-panel,[dir="rtl"] .stl-panel *{direction:rtl}' +
// Numbers stay LTR inside an RTL line: a count or a percentage is a left-to-right run even in Arabic,
// and without isolation the bidi algorithm reorders a run that STARTS with a digit. Same fix the
// packet needed for scripture references.
'[dir="rtl"] .stl-panel .stl-count,[dir="rtl"] .stl-panel .stl-pop{direction:ltr;unicode-bidi:isolate}' +
// ★ THE TWO LEGEND-CONTENT RULES THAT USED TO SIT HERE ARE DELETED (2026-09-27), and they were NOT
// equally provisional — one was redundant and the other was DEAD, which is worth separating because
// only one of them was ever doing anything.
//
// '[dir="rtl"] .stl-panel,[dir="rtl"] .stl-panel *{direction:rtl}'
// REDUNDANT. It matched real elements (.stl-panel appears 69 times in SemanticTreeLegend) but the
// library gets there on its own: it uses the :dir(rtl) PSEUDO-CLASS (:645-646), which reads the
// INHERITED direction rather than needing a declaration, and CSS `direction` inherits across the
// shadow boundary. rega measured the consequence at main 4ca2dcf9 (row 56b): Simple Research and
// Prayer Progress, which carry NO override at all, compute direction rtl on their legend rows under
// ?lang=ar. A rule whose effect is indistinguishable from its absence on two other profiles is not
// load-bearing here either.
//
// '[dir="rtl"] .stl-panel .stl-count,[dir="rtl"] .stl-panel .stl-pop{direction:ltr;unicode-bidi:isolate}'
// ⚠ DEAD CSS, and it always was. The library's count and population cells are `.stl-num` (:1314-1315).
// `.stl-count` and `.stl-pop` DO NOT EXIST — `git grep` finds those two class names nowhere in
// library/ or any other plugin, only inside this override. So this rule matched zero elements from the
// day it was written, and the digit order rega verified ("694", "1,413" keeping their order under ar)
// was always the library's doing, never mine.
// ★ THE LESSON IS ABOUT THE EVIDENCE, NOT THE CSS: this rule was written to fix a real bidi problem,
// was believed to have fixed it, and was cited as the reason the numbers read correctly — while
// matching nothing. A style rule fails SILENTLY when its selector is wrong; it does not warn, and the
// page looks right if something else is already handling it. The only way to know a rule works is to
// check that its selector exists, which is a different act from looking at the rendered page.
// (dx-shepherd UPGDEL authorised deleting both as provisional; recording here that one of the two was
// never provisional but inert, so nobody re-adds it believing it once worked.)
// ⚠ AND THE REOPEN BUTTON, which I missed on my first pass and repo caught. SemanticTreeLegend has
// TWO physical `left:8px` rules, not one: :619 for the panel and :921 for .stl-reopen, the button that
// brings the panel back after it is collapsed. Mirroring only the panel would leave the button on the
@ -2192,10 +2210,10 @@ onMounted(async () => {
// and it overlapped nothing: a promise created after an await starts only once that await
// resolves, so the code read as parallel and behaved strictly serially.
const boundaryPromises = {
adm0: getJSON('world-adm0.geojson'),
adm0: getJSON('world-adm0-web.geojson'),
adm1Chn: getJSON('gb-CHN-ADM1.geojson'),
// 1,628 KB, and only the India pg_slugs zones need it.
adm1Ind: getJSON('gb-IND-ADM1.geojson'),
adm1Ind: getJSON('gb-IND-ADM1-web.geojson'),
}
// ── live records WITH rop1 + population (full record; LESSON L3 / slice 04u) ──

View file

@ -193,14 +193,66 @@ function paneElement(pane) {
// `lang` is the alias the page may carry; children read `locale`.
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
// ★ NO "TAP A COLOURED DOT" COACH-MARK ON THIS PROFILE, AT ALL (Driver, 2026-09-27).
// ProfileLoader mounts PinHint by default and self-gates it on a map <canvas> existing, which is a
// sound gate for the question it asks ("is there a map here?") and the WRONG question for this
// profile. UPG-100's map is a POSTER PREVIEW: the reader is looking at what will be printed, and there
// is no per-dot detail to open. So the pill invites a gesture that does nothing, and on the phone the
// Driver's screenshots show it over a page with no coloured dots on it at all.
// ⚠ THIS SUPERSEDES THE inert WORK FOR THIS PROFILE, and that is worth saying plainly rather than
// quietly leaving both in: PILLRISK was "the pill shows over the posters", and I fixed WHERE it shows.
// The Driver's answer is that it should not show HERE, full stop — a narrower and more correct claim
// than mine. The inert signal stays because it is still right for its own reasons (a stacked pane
// should not be in tab order or the a11y tree, and studio's PinHint change serves profiles that DO
// want the pill), but it is no longer what keeps this pill off this app.
// Opt-out via the contract that already existed — ProfileLoader.vue:150 reads
// `config.pinHint !== false` — so this is a config value from my side, not a library change.
pcfg.pinHint = false
if (tk.value) pcfg.tk = tk.value
el.setAttribute('profile-config', JSON.stringify(pcfg))
return el
}
// ★ TELL THE INVISIBLE PANE THAT IT IS INVISIBLE. `inert` on every pane except the active one.
//
// THE DEFECT, reported by the Driver and traced by repo (PILLRISK, 2026-09-27): PinHint's "Tap a
// coloured dot" pill appeared OVER the Prayer Resources posters on a phone. Its IntersectionObserver
// was working correctly — that is the point. The panes are `position:absolute; inset:0` in one
// scroll-snap column, so the map pane is STACKED BEHIND the active view at full size: 100% of its
// geometry is inside the viewport while none of it is visible. An IntersectionObserver measures
// INTERSECTION, not OCCLUSION, and no amount of tuning it can distinguish those two states. The
// missing information was never geometric — only the composite knows which pane the reader chose.
//
// ⚠ inert, NOT display:none OR v-show, and this is the constraint that decides the whole approach:
// hiding the pane would tear down the live map. A MapLibre/Mapbox canvas in a zero-size container
// loses its size and re-initialises on the way back, which is exactly the cold-start path rega had to
// verify in row 52. inert leaves the pane mounted, painted and warm; it only declares it non-interactive
// — and, unlike aria-hidden, also removes it from tab order and the accessibility tree, so a keyboard
// user cannot tab into a map they cannot see. (studio's contract, END-STU-PILL: PinHint walks up through
// shadow roots for inert/hidden/aria-hidden and watches that chain with a MutationObserver, so no event
// is needed and switching views brings the pill straight back.)
//
// ★ CORRECT AT EVERY VIEWPORT, not just the phone. pane-wrapper renders ONE column with
// `scroll-snap-type: y mandatory` and `.pane { height: 100% }`, so exactly one pane fills the stage on
// any width — there is no side-by-side mode in v1 whose second pane would be wrongly marked. Verified
// in pane-wrapper.js:56-59 + :101 rather than assumed from the phone report.
function markActivePane(activeId) {
if (!column) return
for (const pane of column.panes) {
const el = column.mountEl(pane.id)
if (!el) continue
if (pane.id === activeId) el.removeAttribute('inert')
else el.setAttribute('inert', '')
}
}
function go(id) {
view.value = id
column?.scrollTo(id)
// Immediately, not on the scroll callback: scrollTo is SMOOTH, so onScroll's 120ms debounce fires
// only after the animation settles. Waiting for it would leave the pill visible over the posters for
// the length of the scroll — a shorter version of the same bug.
markActivePane(id)
}
// Cross-pane action (shell seam): the poster wall dispatches a scoped window event
@ -253,10 +305,21 @@ onMounted(() => {
paneEls[pane.id] = el
column.mountEl(pane.id)?.appendChild(el)
}
// ★ THE FRESH-LOAD CASE, which is the one the Driver actually hit. Without this the panes carry no
// inert attribute at all until the reader touches the switcher, so the pill is over the posters on
// ARRIVAL — before any view change has happened. A signal that only fires on transitions never
// describes the initial state.
markActivePane(column.current())
// Tabs are a readout as well as a control — follow the user's scroll.
offScroll = column.onScroll(() => {
const cur = column.current()
if (cur && cur !== view.value) view.value = cur
// Unconditionally, NOT inside the `cur !== view.value` guard above: go() has already set view.value
// before this fires, so a switcher-driven scroll takes the early exit and would skip the marking. A
// scroll-driven one needs it and a switcher-driven one is idempotent, so doing it always is both
// cheaper to reason about and the only version that is correct on both paths.
if (cur) markActivePane(cur)
})
window.addEventListener('doxa-upg-100-list:show-on-map', onShowOnMap)
})

View file

@ -74,7 +74,7 @@ useShadowStyles(
<div class="pdm-root">
<div class="pdm-bar">
<span class="pdm-brand">PPLR data maps</span>
<div class="pdm-seg" role="tablist" aria-label="Choose a view">
<div class="pdm-seg" role="tablist" :aria-label="t('pplr.ui.chooseView')">
<button v-for="v in VIEWS" :key="v.key" role="tab" :aria-selected="view === v.key"
:class="{ on: view === v.key }" @click="setView(v.key)">{{ t(v.tKey) }}</button>
</div>

View file

@ -9,6 +9,7 @@
* component into its toolbar.
*/
import { ref } from 'vue'
import { usePplrT } from '../composables/usePplrT.js'
defineProps({
/** Array of { label, url, fields[] } */
@ -16,19 +17,20 @@ defineProps({
})
const open = ref(false)
const t = usePplrT()
function toggle() { open.value = !open.value }
</script>
<template>
<div class="dsd-wrap">
<button class="toolbar-btn round" type="button" title="Data sources" @click="toggle">
<button class="toolbar-btn round" type="button" :title="t('pplr.ui.dataSources')" @click="toggle">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="open" class="dsd-panel">
<div class="dsd-header">Data sources</div>
<div class="dsd-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in sources" :key="src.url" class="dsd-src">
<div class="dsd-src-label">{{ src.label }}</div>
<div class="dsd-src-url">{{ src.url }}</div>

View file

@ -20,6 +20,7 @@
*/
import { ref, computed } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({
items: { type: Array, default: () => [] },
@ -30,6 +31,7 @@ const props = defineProps({
const emit = defineEmits(['select'])
const instance = useInstance()
const t = usePplrT()
const collapsed = ref(false)
const theme = computed(() => instance?.theme.value || 'dark')
@ -77,7 +79,7 @@ function clearSel() {
</svg>
</button>
<div class="fl-title">{{ title }}</div>
<button v-if="selectedKey" class="fl-clear" title="Clear selection (Esc)" @click="clearSel">×</button>
<button v-if="selectedKey" class="fl-clear" :title="t('pplr.ui.clearSelectionEsc')" @click="clearSel">×</button>
</div>
<div class="fl-rows" :class="{ 'has-sel': !!selectedKey }">
<button
@ -99,12 +101,12 @@ function clearSel() {
<button v-if="collapsed"
class="fl-reopen"
:data-theme="theme"
title="Open legend"
:title="t('pplr.ui.openLegend')"
@click="collapsed = false">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<polyline points="9,18 15,12 9,6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Legend</span>
<span>{{ t('pplr.ui.legend') }}</span>
</button>
</template>

View file

@ -12,12 +12,14 @@
* <LoadingGlobe v-if="!isReady" :label="loadingStage" @retry="retry" />
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({
label: { type: String, default: 'Loading map' },
label: { type: String, default: '' }, // empty = the translated "Loading map"
stuckAfterMs:{ type: Number, default: 30000 },
})
const emit = defineEmits(['retry'])
const t = usePplrT()
const stuck = ref(false)
let stuckTimer = null
@ -56,13 +58,13 @@ function onRetry() {
<line x1="6" y1="32" x2="58" y2="32" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
<line x1="32" y1="6" x2="32" y2="58" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
</svg>
<span class="lg-text">{{ label }}</span>
<span class="lg-text">{{ label || t('pplr.ui.loadingMap') }}</span>
<button v-if="stuck" class="lg-retry" type="button" @click="onRetry">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-3-6.7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<polyline points="21 4 21 11 14 11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Retry
{{ t('pplr.ui.retry') }}
</button>
</div>
</template>

View file

@ -19,6 +19,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
import { LOCALES, LOCALE_NAMES } from '../utils/pplrMap.js'
import { loadNames } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({
/** Optional override — usually read from instance.pinDetail */
@ -30,6 +31,7 @@ const props = defineProps({
const emit = defineEmits(['close', 'locate'])
const instance = useInstance()
const tr = usePplrT() // not `t`: the names table below is already called t
const pinData = computed(() => props.pin || instance?.pinDetail.value || null)
// All 17 locale names for this PG (n_en, n_hi, n_bn, …). Per user 2026-05-02:
@ -59,12 +61,12 @@ const fields = computed(() => {
const dash = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
// Curated field order — most identifying first, registry IDs last
return [
{ label: 'Country', value: dash(p.country), mono: false },
{ label: 'Religion', value: dash(p.religion), mono: false },
{ label: 'Language', value: dash(p.language), mono: false },
{ label: 'ISO-3 Country', value: dash(p.iso3), mono: true, hidden: !p.iso3 },
{ label: 'Affinity Bloc', value: dash(p.rop1), mono: true, hidden: !p.rop1 },
{ label: 'People Cluster', value: dash(p.rop2), mono: true, hidden: !p.rop2 },
{ label: tr('pplr.popup.country'), value: dash(p.country), mono: false },
{ label: tr('pplr.popup.religion'), value: dash(p.religion), mono: false },
{ label: tr('pplr.popup.language'), value: dash(p.language), mono: false },
{ label: tr('pplr.popup.iso3Country'), value: dash(p.iso3), mono: true, hidden: !p.iso3 },
{ label: tr('pplr.popup.affinityBloc'), value: dash(p.rop1), mono: true, hidden: !p.rop1 },
{ label: tr('pplr.popup.peopleCluster'), value: dash(p.rop2), mono: true, hidden: !p.rop2 },
{ label: 'ROP25', value: dash(p.rop3), mono: true, hidden: !p.rop3 },
{ label: 'PGIC GeoID', value: dash(p.rop25), mono: true, hidden: !p.rop25 },
{ label: 'PEID', value: dash(p.peid), mono: true, hidden: !p.peid },
@ -98,8 +100,8 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
<!-- Title + close -->
<div class="pdc-header">
<span class="pdc-eyebrow">People Group</span>
<button class="pdc-close" title="Close (Esc)" @click="close">
<span class="pdc-eyebrow">{{ tr('pplr.popup.peopleGroup') }}</span>
<button class="pdc-close" :title="tr('pplr.ui.closeEsc')" @click="close">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
@ -117,7 +119,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</div>
<div class="pdc-section-divider">
<span>Name in 17 locales</span>
<span>{{ tr('pplr.card.nameInLocales', { n: locales.length }) }}</span>
</div>
<div v-for="loc in locales" :key="loc.code" class="pdc-field pdc-locale">
<span class="pdc-field-label">{{ loc.name }} <code>{{ loc.code }}</code></span>
@ -134,9 +136,9 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
<path d="M12 21s7-7 7-12a7 7 0 1 0-14 0c0 5 7 12 7 12z" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="9" r="2.5" stroke="currentColor" stroke-width="2"/>
</svg>
Fly to pin
{{ tr('pplr.ui.flyToPin') }}
</button>
<button class="pdc-btn" @click="close">Close</button>
<button class="pdc-btn" @click="close">{{ tr('pplr.ui.close') }}</button>
</div>
</div>
</template>

View file

@ -34,6 +34,7 @@ const A11Y_EN = {
'legend.loadingRows': 'Loading {label}…', 'legend.loadingData': 'Loading data…',
'legend.noDataAtThisLevel': 'No data at this level', 'legend.showingTop': 'Showing top {n} of {m}',
'legend.useGeocoderHint': 'use the geocoder above to find the rest',
'legend.sortByPop': 'Sort by Population',
}
const fillA11y = (str, p) => String(str).replace(/\{(\w+)\}/g, (m, k) => (p && k in p ? p[k] : m))
let ta = (key, p) => fillA11y(A11Y_EN[key] ?? key, p)
@ -441,7 +442,7 @@ function rowClasses(row) {
</button>
<button v-if="hasPop" class="stl-hdr-num"
:class="{ active: sortBy === 'pop' }"
title="Sort by Population"
:title="ta('legend.sortByPop')"
@click="clickSortHeader('pop')">
Pop
<span v-if="sortBy === 'pop'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>

View file

@ -30,6 +30,7 @@ export const LOCALE_NAMES = {
// ── PPLR FeatureServer — country polygons (live, not bundled) ────────────────
import { dataUrl, glFor } from './pplrData.js'
import { tStatic } from '@map/i18n/index.js'
export const COUNTRY_FS_URL = 'https://services2.arcgis.com/S4ydGgujXcif36k3/arcgis/rest/services/aCountry/FeatureServer/0/query'
export const COUNTRY_FS_FIELDS = ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp']
@ -332,17 +333,20 @@ export const escHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&':
export function popupForPG(p, extraRows = '') {
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : escHtml(v))
// Labels in the page's language (tStatic falls back to English). Registry names (ROP3, ROP25,
// JoshuaProject, IMB People, WCD) are identifiers and stay as they are.
const L = (k) => escHtml(tStatic('pplr.popup.' + k))
return `<div class="pplr-popup">
<h3>${p.name ? escHtml(p.name) : '(no name)'}</h3>
<h3>${p.name ? escHtml(p.name) : L('noName')}</h3>
<table>
<tr><td>Country</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
<tr><td>${L('country')}</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
<tr><td>ROP3</td><td><code>${dash(p.rop3)}</code></td></tr>
<tr><td>ROP25 (Ethne)</td><td><code>${dash(p.rop25)}</code></td></tr>
<tr><td>JoshuaProject</td><td><code>${dash(p.jp)}</code></td></tr>
<tr><td>IMB People</td><td><code>${dash(p.imb)}</code></td></tr>
<tr><td>WCD</td><td><code>${dash(p.wcd)}</code></td></tr>
<tr><td>Religion</td><td>${dash(p.religion)}</td></tr>
<tr><td>Language</td><td>${dash(p.language)}</td></tr>
<tr><td>${L('religion')}</td><td>${dash(p.religion)}</td></tr>
<tr><td>${L('language')}</td><td>${dash(p.language)}</td></tr>
${extraRows}
</table>
</div>`

View file

@ -103,8 +103,8 @@ onMounted(async () => {
const html = `<div class="pplr-popup">
<h3>${escHtml(p.CtryNm || p.ctryNmDisp || '—')}<small>${escHtml(p.GENCalpha3 || p.ISOalpha2 || '?')}</small></h3>
<table>
<tr><td>PG count</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
<tr><td>Avg locales filled</td><td>
<tr><td>${escHtml(t('pplr.popup.pgCount'))}</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.avgLocalesFilled'))}</td><td>
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
</table>
</div>`
@ -148,9 +148,9 @@ const COV_POPUP_CSS = POPUP_CSS + `
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Translation coverage · choropleth = avg locales filled per country · click PG pin or country</div>
<div class="header-pill">{{ t('pplr.pill.coverage') }}</div>
<div class="legend">
<div class="title">Avg locales / 17</div>
<div class="title">{{ t('pplr.legendTitle.avgLocales', { n: 17 }) }}</div>
<div class="bar">
<div v-for="s in SCALE" :key="s.stop" class="seg" :style="{ background: s.color }">
<span v-if="s.label">{{ s.label }}</span>

View file

@ -65,7 +65,7 @@ onMounted(async () => {
<template>
<div class="profile">
<LoadingGlobe v-if="!tree" label="Loading ROP hierarchy" @retry="reload" />
<LoadingGlobe v-if="!tree" :label="t('pplr.status.loadingRop')" @retry="reload" />
<template v-else>
<SemanticSunburst
:tree="tree"
@ -74,18 +74,18 @@ onMounted(async () => {
:depth-labels="['','Affinity Bloc','People Cluster','PG (not rendered)']"
:max-depth="2"
/>
<div class="header-pill">ROP Hierarchy · Affinity Bloc → People Cluster · scroll to zoom</div>
<div class="header-pill">{{ t('pplr.pill.sunburst') }}</div>
<div class="status">{{ status }}</div>
<div class="map-toolbar">
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -415,10 +415,10 @@ function popupHtml(p) {
return `<div class="pplr-popup">
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>Family</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>
<tr><td>Affinity Bloc</td><td>${d(p.rop1)}</td></tr>
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.family'))}</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.affinityBloc'))}</td><td>${d(p.rop1)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td>${d(p.religion)}</td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
</table>
@ -522,7 +522,7 @@ const LF_CSS = POPUP_CSS + `
:local-index="langLocalIndex"
:kind-labels="LANG_KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
:placeholder="t('pplr.ui.search')"
@place="onGeocoderPlace"
/>
</div>
@ -539,13 +539,13 @@ const LF_CSS = POPUP_CSS + `
v-else
:nodes="langTree"
:tabs="LANG_TABS"
title="Language Families"
:title="t('pplr.tabs.languageFamilies')"
@select="onLegendSelect"
/>
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Ethnolinguistic families (PPLR) · click pin for detail</div>
<div class="header-pill">{{ t('pplr.pill.languageFamilies') }}</div>
<!-- Toolbar — rounded pill controls -->
<div class="map-toolbar">
@ -564,28 +564,28 @@ const LF_CSS = POPUP_CSS + `
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')" @click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
:class="{ active: currentBasemap === b.key }"
@click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -68,18 +68,18 @@ onBeforeUnmount(() => destroy())
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">All PG-in-country pins · click for cross-system IDs</div>
<div class="header-pill">{{ t('pplr.pill.pins') }}</div>
<div class="map-toolbar">
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -236,8 +236,8 @@ function popupHtml(p) {
return `<div class="pplr-popup">
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Religion</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
</table>
@ -294,7 +294,7 @@ const REL_CSS = POPUP_CSS + `
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Religion distribution · click pin for detail</div>
<div class="header-pill">{{ t('pplr.pill.religion') }}</div>
<!-- Decoupled geocoder — searches religions + countries + places -->
<div class="geocoder-slot">
@ -302,7 +302,7 @@ const REL_CSS = POPUP_CSS + `
:local-index="religionLocalIndex"
:kind-labels="REL_KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
:placeholder="t('pplr.ui.search')"
@place="onGeocoderPlace"
/>
</div>
@ -318,7 +318,7 @@ const REL_CSS = POPUP_CSS + `
<FlatLegend
v-else
:items="religionLegendItems"
title="Primary Religion"
:title="t('pplr.ui.primaryReligion')"
@select="onLegendSelect"
/>
@ -339,27 +339,27 @@ const REL_CSS = POPUP_CSS + `
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')" @click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt" :class="{ active: currentBasemap === b.key }" @click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<!-- Data sources — moved into the toolbar to match ROP-tree (per user 2026-05-02) -->
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel toolbar-attached">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -380,14 +380,14 @@ function popupHtml(p) {
return `<div class="pplr-popup">
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Affinity Bloc</td><td><b>${d(p.rop1)}</b></td></tr>
<tr><td>Cluster</td><td><b>${d(p.rop2)}</b></td></tr>
<tr><td>People Group</td><td><b>${d(p.rop3)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.affinityBloc'))}</td><td><b>${d(p.rop1)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.cluster'))}</td><td><b>${d(p.rop2)}</b></td></tr>
<tr><td>${escHtml(t('pplr.popup.peopleGroup'))}</td><td><b>${d(p.rop3)}</b></td></tr>
<tr><td>PEID</td><td><code>${d(p.peid)}</code></td></tr>
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td>${d(p.religion)}</td></tr>
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
</table>
</div>`
}
@ -491,7 +491,7 @@ const TREE_POPUP_CSS = POPUP_CSS + `
:local-index="localIndex"
:kind-labels="KIND_LABELS"
:mapbox-token="mapboxToken"
placeholder="Search by people, place, language, religion…"
:placeholder="t('pplr.ui.search')"
@place="onGeocoderPlace"
/>
</div>
@ -508,13 +508,13 @@ const TREE_POPUP_CSS = POPUP_CSS + `
v-else
:nodes="ropTree"
:tabs="ROP_TABS"
title="ROP Hierarchy"
:title="t('pplr.ui.ropHierarchy')"
@select="onRopLegendSelect"
/>
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">ROP Hierarchy · Affinity Bloc → Cluster → People Group → PGIC</div>
<div class="header-pill">{{ t('pplr.pill.ropTree') }}</div>
<!-- Toolbar — rounded pill controls -->
<div class="map-toolbar">
@ -533,7 +533,7 @@ const TREE_POPUP_CSS = POPUP_CSS + `
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
</button>
<div class="basemap-wrap">
<button class="toolbar-btn round" title="Base map"
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')"
@click="showBasemap = !showBasemap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
@ -541,21 +541,21 @@ const TREE_POPUP_CSS = POPUP_CSS + `
</svg>
</button>
<div v-if="showBasemap" class="basemap-panel">
<div class="basemap-title">Base map</div>
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
:class="{ active: currentBasemap === b.key }"
@click="setBasemap(b.key)">{{ b.label }}</button>
</div>
</div>
<div class="settings-wrap">
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</button>
<div v-if="showSettings" class="settings-panel">
<div class="settings-header">Data sources</div>
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
<div class="settings-src-label">{{ src.label }}</div>
<div class="settings-src-url">{{ src.url }}</div>

View file

@ -16,7 +16,7 @@ import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS, escHtml } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import DataSourcesDropdown from '../components/DataSourcesDropdown.vue'
@ -123,11 +123,11 @@ onMounted(async () => {
].filter(Boolean)
const rosettaRow = sample
? `<tr><td>Rosetta ★</td><td><b style="color:#d97706">★ sample PG</b></td></tr>`
? `<tr><td>Rosetta ★</td><td><b style="color:#d97706">${escHtml(t('pplr.rosetta.samplePg'))}</b></td></tr>`
: ''
const missingNote = missingIds.length
? `<tr><td colspan="2" style="padding-top:6px;font-size:10px;color:#6b7280;font-style:italic">
${missingIds.join(', ')} ID${missingIds.length > 1 ? 's' : ''} not yet cross-mapped in PPLR — empty rows are a data gap, not an error.
${escHtml(t('pplr.rosetta.missingIds', { ids: missingIds.join(', ') }))}
</td></tr>`
: ''
new (glFor(map.value)).Popup({ maxWidth: '360px' })
@ -154,11 +154,11 @@ onMounted(async () => {
onBeforeUnmount(() => destroy())
const LEGEND = [
{ color: '#fbbf24', label: 'JP + IMB + WCD all' },
{ color: '#fbbf24', label: t('pplr.rosetta.legend.all') },
{ color: '#3b82f6', label: 'JP + IMB' },
{ color: '#a78bfa', label: 'IMB only' },
{ color: '#ec4899', label: 'JP only' },
{ color: '#6b7280', label: 'neither' },
{ color: '#a78bfa', label: t('pplr.rosetta.legend.imbOnly') },
{ color: '#ec4899', label: t('pplr.rosetta.legend.jpOnly') },
{ color: '#6b7280', label: t('pplr.rosetta.legend.neither') },
]
</script>
@ -167,9 +167,9 @@ const LEGEND = [
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
<div ref="mapContainer" class="map" />
<div class="status">{{ status }}</div>
<div class="header-pill">Cross-system ID Rosetta · color = which IDs exist · ★ = sample row</div>
<div class="header-pill">{{ t('pplr.pill.rosetta') }}</div>
<div class="legend">
<div class="title">ID coverage</div>
<div class="title">{{ t('pplr.legendTitle.idCoverage') }}</div>
<div v-for="l in LEGEND" :key="l.label" class="row">
<span class="dot" :style="{ background: l.color }" />{{ l.label }}
</div>

View file

@ -274,7 +274,7 @@ function addLayers() {
// Add EMPTY first, then setData after the fetch: the addLayer calls below must run
// SYNCHRONOUSLY to keep these fills beneath the pins (layer order is insertion order).
m.addSource(SRC_COUNTRIES, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(profileConfig?.value)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(profileConfig?.value)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
for (const f of gj.features || []) {

View file

@ -834,7 +834,7 @@ function addLayers() {
// keep this layer underneath the pins (layer order is insertion order). An awaited fetch
// here would put the choropleth on TOP of every bubble.
m.addSource('rd-countries', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0.geojson`)
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0-web.geojson`)
.then((r) => r.json())
.then((gj) => {
for (const f of gj.features || []) {

View file

@ -146,8 +146,8 @@ useShadowStyles(`
<input class="cpt-search" type="search" :value="store.search.value" @input="onSearch"
:placeholder="$t('neighbor.searchCockpit')" :aria-label="$t('neighbor.search')" />
<span class="cpt-counter">
<b>{{ store.counts.value.senders.toLocaleString() }}</b> senders →
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> clusters · {{ store.counts.value.hidden }} hidden
<b>{{ store.counts.value.senders.toLocaleString() }}</b> {{ $t('neighbor.cockpit.senders') }}
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> {{ $t('neighbor.cockpit.clustersHidden', { n: store.counts.value.hidden }) }}
</span>
<span class="cpt-prov" :class="{ err: store.provenance.value.apiError }"
:title="store.provenance.value.reachedFilter">
@ -163,8 +163,8 @@ useShadowStyles(`
<div class="cpt-cell map"><MapCell /></div>
<div class="cpt-cell flower"><FlowerCell /></div>
<div class="cpt-seg" role="tablist" :aria-label="$t('neighbor.cockpit.viewSwitch')">
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">MAP</button>
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">FLOWER</button>
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">{{ $t('neighbor.cockpit.map') }}</button>
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">{{ $t('neighbor.cockpit.flower') }}</button>
</div>
</div>
@ -177,13 +177,13 @@ useShadowStyles(`
<!-- LEGEND RIBBON (one shared key for all four cells) -->
<div class="cpt-legend">
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.RED }"></span> {{ $t('neighbor.cockpit.unreachedTarget') }}</span>
<span class="cpt-lg">reached %
<span class="cpt-lg">{{ $t('neighbor.strip.pctReached') }}
<span class="cpt-ramp">
<i v-for="(g, i) in COLORS.GREEN_BINS" :key="i" :style="{ background: g }"></i>
</span>
</span>
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> SEND (solid=same-lang · dashed=cluster · dotted=in-country)</span>
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> selection · pop = log radius · Luke 10:2</span>
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> {{ $t('neighbor.cockpit.legendSend') }}</span>
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> {{ $t('neighbor.cockpit.legendSelection') }}</span>
</div>
<!-- loading / error overlay -->

View file

@ -395,8 +395,8 @@ useShadowStyles(
<!-- ring legend -->
<div v-if="placed" class="cf-rings">
<b>inner</b> same-country · <b>petals</b> cross-border<br>
navy = SEND · <span :style="{ color: COLORS.GOLD }">gold</span> = same-language mesh
{{ $t('neighbor.fm.whorl') }}<br>
{{ $t('neighbor.cockpit.navySend') }} · <span :style="{ color: COLORS.GOLD }">{{ $t('neighbor.cockpit.goldMesh') }}</span>
</div>
</div>

View file

@ -328,9 +328,9 @@ useShadowStyles(`
`, 'flower-clusters')
const MODES = [
{ key: 'family', label: 'Family', sz: 'fewer · bigger' },
{ key: 'language', label: 'Language', sz: 'more' },
{ key: 'dialect', label: 'Dialect', sz: 'most' }
{ key: 'family', tKey: 'neighbor.fc.mode.family', szKey: 'neighbor.fc.size.fewerBigger' },
{ key: 'language', tKey: 'neighbor.fc.mode.language', szKey: 'neighbor.fc.size.more' },
{ key: 'dialect', tKey: 'neighbor.fc.mode.dialect', szKey: 'neighbor.fc.size.most' }
]
</script>
@ -343,9 +343,9 @@ const MODES = [
<div class="fc-modes" role="tablist" :aria-label="$t('neighbor.fc.clusterBy')">
<button v-for="md in MODES" :key="md.key" class="fc-mode" :class="{ on: mode === md.key }"
role="tab" :aria-selected="mode === md.key" @click="setMode(md.key)">
<span class="fc-circle">{{ md.label.slice(0,3).toUpperCase() }}</span>
<span class="fc-mode-lbl">{{ md.label }}</span>
<span class="fc-mode-sz">{{ md.sz }}</span>
<span class="fc-circle">{{ md.key.slice(0,3).toUpperCase() }}</span>
<span class="fc-mode-lbl">{{ $t(md.tKey) }}</span>
<span class="fc-mode-sz">{{ $t(md.szKey) }}</span>
</button>
</div>

View file

@ -63,12 +63,12 @@ const hiddenCount = computed(() => counts.value.hidden || 0)
// ── sort options ──────────────────────────────────────────────────────────────
const SORTS = [
{ key: 'mobilization', label: 'Mobilization' },
{ key: 'pct', label: '%Christian' },
{ key: 'pop', label: 'Population' },
{ key: 'bridge', label: 'Bridge' },
{ key: 'name', label: 'Name' },
{ key: 'frontier', label: 'Frontier first' }
{ key: 'mobilization', tKey: 'neighbor.list.sortMobilization' },
{ key: 'pct', tKey: 'neighbor.list.colPctChristian' },
{ key: 'pop', tKey: 'neighbor.list.colPopulation' },
{ key: 'bridge', tKey: 'neighbor.strip.bridgeType' },
{ key: 'name', tKey: 'neighbor.list.colName' },
{ key: 'frontier', tKey: 'neighbor.list.sortFrontier' }
]
function pickSort(key) { store.setSort(key) }
@ -303,7 +303,7 @@ useShadowStyles(`
:class="{ active: sortKey === s.key, frontier: s.key === 'frontier' }"
type="button"
@click="pickSort(s.key)"
>{{ s.label }}</button>
>{{ $t(s.tKey) }}</button>
</div>
</div>
@ -363,7 +363,7 @@ useShadowStyles(`
class="cpt-l-info"
:class="{ on: pinnedInfo === r.id }"
type="button"
title="Details"
:title="$t('neighbor.cockpit.details')"
@click.stop="toggleInfo(r.id)"
>i</button>
</div>
@ -374,12 +374,12 @@ useShadowStyles(`
<dt>{{ $t('neighbor.list.colPeopleGroup') }}</dt><dd>{{ r.name }}</dd>
<dt>{{ $t('neighbor.list.colCountry') }}</dt><dd>{{ r.country }}</dd>
<dt>{{ $t('neighbor.list.colLanguage') }}</dt><dd>{{ r.lang }}<span v-if="r.rol3"> · {{ r.rol3 }}</span></dd>
<dt>%Christian</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
<dt>{{ $t('neighbor.list.colPctChristian') }}</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
<dt>{{ $t('neighbor.list.colPopulation') }}</dt><dd>{{ fmtPop(r.pop) }}</dd>
<dt>Bridge</dt><dd>{{ r.bridge }}</dd>
<dt>Adjacency</dt><dd>{{ r.adjacency }}</dd>
<dt>{{ $t('neighbor.strip.bridgeType') }}</dt><dd>{{ r.bridge }}</dd>
<dt>{{ $t('neighbor.list.colAdjacency') }}</dt><dd>{{ r.adjacency }}</dd>
<dt>{{ $t('neighbor.list.colReligion') }}</dt><dd>{{ r.religion_bg || r.religionFamily }}</dd>
<dt v-if="r.code">Code</dt><dd v-if="r.code">{{ r.code }}</dd>
<dt v-if="r.code">{{ $t('neighbor.list.colCode') }}</dt><dd v-if="r.code">{{ r.code }}</dd>
</dl>
</div>
</div>

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-JoGdbQlwjgq9+dOQy3eDJ1DjLwyZ6VbclEci++67/f8faF0HOa1ox10mkNn1fj73" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-3rrykWo456uGE2YmJ/i7AeehqxCWQe0bVDjDOj5VEwaPZV5pFw4iegauoNaZMyDR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
"meta": {
"title": "Messaging & App Channels",
"grain": "one row per country x communication app",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
@ -2830,19 +2830,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Thailand",
"iso3": "THA",
"region": "Asia",
"app": "LINE MAN: Food Delivery & more",
"kind": "messaging",
"rank": 13,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Burkina Faso",
"iso3": "BFA",
@ -3006,7 +2993,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 37,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 8,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
@ -7809,19 +7796,6 @@
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Maldives",
"iso3": "MDV",
"region": "Asia",
"app": "Malé Taxi Line",
"kind": "messaging",
"rank": 1,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
"source": "Apple App Store RSS — top free apps by country"
},
{
"country": "Northern Mariana Islands",
"iso3": "MNP",
@ -13120,7 +13094,7 @@
"app": "WhatsApp Messenger",
"kind": "messaging",
"rank": 33,
"is_top_messaging": false,
"is_top_messaging": true,
"people_groups": 1,
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
"classification": "name-keyword HEURISTIC, not a verified taxonomy",

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-xwzODwX387yfNONPEKpKzvyroMMl53Q2sNDSad+GSend5mDN4FG4BWR0OIQNjdty" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

Some files were not shown because too many files have changed in this diff Show more