Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work

# Conflicts:
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-resources/charts/index.html
#	public/dx-maps/gospel-resources/data-tables/index.html
#	public/dx-maps/gospel-resources/gospel-dashboard/index.html
#	public/dx-maps/gospel-resources/gospel-resources.js
#	public/dx-maps/gospel-resources/resources-list/index.html
#	public/dx-maps/gospel-resources/resources-map/index.html
#	public/dx-maps/manifest.json
This commit is contained in:
Doxa Maps 2026-09-27 04:17:14 +00:00
commit 873d73f5eb
179 changed files with 6977 additions and 1684 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

@ -84,6 +84,8 @@ and a change here can break other maps.
`import … from '@map/components/poster/slots/TitleSlot.vue'`
- **QaCorner** — QaCorner.vue — the embeddable QA corner widget (bottom-right of any map).
`import … from '@map/components/qa/QaCorner.vue'`
- **RegionSectionCard** — RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
`import … from '@map/components/regions/RegionSectionCard.vue'`
- **FilterDropdown** — FilterDropdown.vue — SHARED (@map/components/search/FilterDropdown.vue).
`import … from '@map/components/search/FilterDropdown.vue'`
- **SearchBar** — SearchBar.vue — SHARED (@map/components/search/SearchBar.vue).
@ -121,6 +123,8 @@ and a change here can break other maps.
`import … from '@map/composables/useCsvExport.js'`
- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
`import … from '@map/composables/useDashboardLayout.js'`
- **useDashboardSearch** — useDashboardSearch.js — the ONE free-text search the whole dashboard shares.
`import … from '@map/composables/useDashboardSearch.js'`
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
`import … from '@map/composables/useDocumentFont.js'`
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.

View file

@ -37,6 +37,14 @@ check('a rendered (tile-rounded) feature still finds its co-located groups', col
const twin = buildColocationIndex([...rows, { name: 'Koraga', latitude: 40, longitude: 40 }, { name: 'Other', latitude: 40, longitude: 40 }])
const renderedFar = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [40.0007, 39.9994] } }
check('a repeated name resolves to the nearest roster point', colocatedWith(twin, renderedFar).join() === 'Other', colocatedWith(twin, renderedFar))
// The rows a map DRAWS are normalized people groups (name + latitude/longitude, no geometry), and every
// map built on useMapData must offer them to its legends: the prayer map did not, so its legends read the
// raw API cache and neither the shared-point sentence nor the standing note appeared (rega, row 46 at 835ec1ac).
const drawn = [{ name: 'A', latitude: 12.85, longitude: 75.25 }, { name: 'B', latitude: '12.85', longitude: '75.25' }]
const clickedA = { type: 'Feature', properties: { name: 'A' }, geometry: { type: 'Point', coordinates: [75.2504, 12.8497] } }
check('normalized rows (no geometry) index and match a clicked pin', colocatedWith(buildColocationIndex(drawn), clickedA).join() === 'B', colocatedWith(buildColocationIndex(drawn), clickedA))
const umd = readFileSync(join(ROOT, 'library/composables/useMapData.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
check('useMapData provides the rows it draws to the legends', /provide\(\s*'normalizedPeopleGroups'\s*,\s*normalizedPeopleGroups\s*\)/.test(umd), 'no provide in useMapData')
const { createServer } = await import('vite')
const { default: vue } = await import('@vitejs/plugin-vue')

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

@ -101,7 +101,7 @@ ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area En
// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the
// catalogue. The second matters more than it looks: routing these through tStatic would change what
// seven bare-host bundles render, and would start translating strings that are English today.
import { readFileSync as rf } from 'node:fs'
import { readFileSync as rf, readdirSync } from 'node:fs'
const SITES = [
// file, helper it must use, a fragment of the fallback it must still pass
['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'],
@ -140,15 +140,30 @@ const SITES = [
['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null],
// wave 3d — all-people-groups. Treated as UNOWNED (studio agreed), so the conservative reading wins:
// its inline EN map stays the fallback rather than switching to the catalogue, because nobody is
// available to decide that this bundle should start honouring the locale on a bare host. An unowned
// file is the wrong place to make a product decision by default.
// available to decide that this bundle should start honouring the locale on a bare host -- THEN
// dx-shepherd RULED the locale-honouring fallback everywhere (2026-09-27; the Driver's goal (d), a
// bare-host embed with ?lang=ar must render Arabic, now the embed contract in COMMON.md). So it takes
// the catalogue default, its duplicate EN map is deleted, and section 9 keeps the ruling's PRECONDITION
// true rather than measured once.
// First site on the {t, te, locale} form: it needs locale for toLocaleString.
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', 'fallback: (full) =>'],
// wave 3c — my-upg-100-list (owner: upg100). Both used the humanizeKey fallback and no namespace,
// since callers pass full keys. Each keeps humanizeKey, so a bare host reads the same title-cased
// last segment it always did.
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', 'humanizeKey(k)'],
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', 'humanizeKey(k)'],
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', null],
// wave 3c — my-upg-100-list (owner: upg100-plugin). No namespace, since callers pass full keys.
// ⚠ THE THIRD COLUMN IS null, NOT 'humanizeKey(k)', and that is a DEPARTURE from 4c05e589, which
// kept the fallback. Correct as regions-plugin wrote it; wrong the moment dx-shepherd took the
// product call (UPGHK, 2026-09-27) to delete it. A row asserting a fallback the code no longer
// passes fails by design — the same hole regions flagged to me in the opposite direction.
// ★ WHY IT WAS DELETED. humanizeKey rendered a key's last segment title-cased, failing two ways: on
// the messages keys it printed "Upg100 Wall Intro" where a whole sentence exists (wrong even in
// English), and on the tabs keys it printed the RIGHT English by coincidence — those keys are named
// after the words — so it looked correct forever while every non-English reader silently got
// English. A fallback that agrees with en is indistinguishable from a working translation until
// someone changes locale.
// The premise for keeping it ("these keys are not fully catalogued") was MINE and was FALSE: seven
// keys across both files, all string literals, every one present in all eleven locales, 0 gaps.
// Same class of decision gospel-plugin took for its four sites above, and the OPPOSITE of the 3d
// reading directly above — which is right, because that file is unowned and these two are not.
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', null],
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', null],
]
// ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived.
// resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the
@ -188,9 +203,20 @@ for (const [file, helper, fallbackFrag] of SITES) {
// catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their
// owner's decision. Saying "as it always did" for those four would be a false statement inside a
// gate message, which is the one place a reader has no reason to doubt.
const moved = file.includes('gospel-resources')
ok(!/fallback:/.test(code), moved
? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)`
// Which sites were MOVED to the catalogue by a decision, vs which always resolved there. Saying "as it
// always did" about a moved file is a false statement inside a gate message, the one place a reader has
// no reason to doubt. gospel-resources and cities-internet moved by their owners' decisions,
// my-upg-100-list by dx-shepherd's product call (UPGHK) deleting humanizeKey, and all-people-groups by
// the embed-contract ruling (locale-honouring everywhere, 2026-09-27).
const MOVED_BY = {
'gospel-resources': "gospel-plugin moved it here from enLookup's English",
'cities-internet': "exp-shepherd moved it here from enLookup's English",
'my-upg-100-list': 'upg100-plugin moved it here from humanizeKey, a title-cased key segment',
'all-people-groups': "dx-shepherd's embed-contract ruling moved it here from its inline English map",
}
const mover = Object.keys(MOVED_BY).find((d) => file.includes(d))
ok(!/fallback:/.test(code), mover
? `${short}: takes the locale-honouring CATALOGUE fallback (${MOVED_BY[mover]})`
: `${short}: takes the CATALOGUE fallback, as it always did`)
}
}
@ -227,6 +253,52 @@ for (const [file, helper, fallbackFrag] of SITES) {
'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required')
}
// ── 9. THE RULING'S PRECONDITION, kept true rather than measured once ───────────────────────────
// Dropping a site's own English fallback is only safe while the catalogue HAS every key that site asks
// for. Before the switch I measured it by hand: 11 keys, 11 locales, 0 gaps. A hand measurement protects
// the day it is taken; this protects every day after, which is the whole difference between a check and
// a note. If a key is added to the source and not catalogued, a bare host now renders the raw dotted
// key -- the exact failure upg100 described, and the reason they had advised keeping a fallback at all.
{
const src = rf(new URL('../plugin-designs/all-people-groups/all-people-groups.vue', import.meta.url), 'utf8')
// ★ RAW SOURCE, NOT codeOnly, AND THE ASYMMETRY IS THE REASON. For "does the catalogue hold every key
// this file asks for", over-including a key (one that only appears in a comment) makes the check
// STRICTER and can only cause a false red that a human resolves in seconds. UNDER-including one makes
// it claim completeness it does not have, which is the failure this section exists to prevent.
// It matters concretely: codeOnly loses `tr('pray')` in this file. That call lives inside a template
// literal whose `https://` was read as a line comment, because the scanner had already lost template
// state further up a 1000-line SFC. See the limitation note in strip-code.mjs. Six keys became seven
// the moment the census stopped stripping.
const code = src
const literal = [...new Set([...code.matchAll(/\btr\(\s*'([A-Za-z][\w.]*)'/g)].map((m) => m[1]))]
// ★ LITERALS ARE NOT ALL OF THEM, and a census that stopped here would have claimed completeness while
// blind to a third of the keys. This file also calls tr(cat.id) and tr(c.id) -- the category ids from
// the CATS array -- so four keys (doxa, frontier, unreached, reached) never appear as a literal
// argument. Extract those ids too, and assert BOTH that they were found and that the dynamic call
// sites still look the way this extraction assumes.
const catIds = [...new Set([...code.matchAll(/\{\s*id:\s*'([a-z]+)'\s*,\s*color:/g)].map((m) => m[1]))]
const dynamic = [...new Set([...code.matchAll(/\btr\(\s*([A-Za-z_$][\w.$]*)/g)].map((m) => m[1]))]
ok(dynamic.length === 0 || catIds.length > 0,
`the dynamic tr() arguments (${dynamic.join(', ') || 'none'}) are covered by the CATS ids (${catIds.join(', ') || 'NONE FOUND'})`)
ok(catIds.length === 4,
`found ${catIds.length} category ids -- if this changes, the dynamic keys this census covers have changed with it`)
const used = [...new Set([...literal, ...catIds])]
ok(used.length > literal.length,
`all-people-groups: ${used.length} keys verified = ${literal.length} literal + ${catIds.length} reached only through tr(cat.id)`)
const locales = readdirSync(new URL('../library/i18n/locales', import.meta.url))
const gaps = []
for (const k of used) for (const loc of locales) {
let v
try { v = JSON.parse(rf(new URL(`../library/i18n/locales/${loc}/allPeopleGroups.json`, import.meta.url), 'utf8'))[k] } catch (_) { v = undefined }
if (v === undefined || v === '') gaps.push(`${loc}:${k}`)
}
ok(gaps.length === 0,
`every key it asks for exists in all ${locales.length} locales${gaps.length ? ` -- MISSING ${gaps.slice(0, 6).join(', ')}` : ''}`)
let invented
try { invented = JSON.parse(rf(new URL('../library/i18n/locales/en/allPeopleGroups.json', import.meta.url), 'utf8')).definitelyNotACataloguedKey } catch (_) {}
ok(invented === undefined, 'NEG CONTROL: an invented key is absent, so the lookup above can actually fail')
}
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
{
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)

View file

@ -17,9 +17,11 @@
// SKIP (exit 0) only when the directory is genuinely absent or empty: packets are
// generated on demand and a clean checkout has none. A skip says so out loud, with the
// count, so "0 packets checked" can never read as "all packets passed".
import { existsSync, readdirSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
// pathToFileURL, not a bare path: a dynamic import() of an absolute POSIX path works by luck on
// linux and throws ERR_UNSUPPORTED_ESM_URL_SCHEME the moment anyone runs this on windows.
import { fileURLToPath, pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
const HERE = dirname(fileURLToPath(import.meta.url))
@ -93,22 +95,68 @@ for (const f of files) {
// PASS line from overclaiming.
const zoneOf = (f) => f.replace(/\.html$/, '').replace(/\.[a-z]{2}$/, '')
const zones = [...new Set(files.map(zoneOf))].sort()
// ★ THE DENOMINATOR NOW COMES FROM THE SAME PLACE THE NUMERATOR DOES.
//
// ⚠ IT DID NOT, AND THAT HID A BUG FOR THREE DAYS. This counted out/posters/*.html — POSTER files —
// while the numerator counts PACKET zones. Two different populations, printed either side of the word
// "of". out/posters is also itself GENERATED, so on a clean checkout the denominator was null and the
// shortfall warning silently vanished: the one line whose job was to stop an overclaim could not fire
// precisely when nothing had been rendered at all.
// MEASURED 2026-09-27: posters = 28, packet-able zones = 27, and the missing one was `deaf` — a legend
// row and a poster with NO reachable packet, because packet.mjs's allZones() looped three cluster keys
// and clusters.json carries zones in a fourth (deaf_lists, collapsed by legend-tree.js into one leaf).
// So "2 of 28" was not 26-to-go: it was 25-to-go plus one that could not be generated by any command.
// Fixed in packet.mjs (it now enumerates via leafZones, deaf included); this line stops the two sides
// from ever drifting again by reading the SAME seam rather than a directory listing of a side effect.
let totalZones = null
let zoneIds = null
try {
const postersDir = resolve(REPO, 'library/scripts/posters/out/posters')
if (existsSync(postersDir)) totalZones = readdirSync(postersDir).filter((f) => f.endsWith('.html')).length
} catch { /* the denominator is a nicety; its absence must not fail the gate */ }
const DATA = resolve(REPO, 'plugin-designs/my-upg-100-list/data')
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
zoneIds = leafZones(buildLegendNodes(clusters, { records: null })).map((z) => z.id).sort()
totalZones = zoneIds.length
} catch (e) {
// The denominator is a nicety and its absence must not fail the gate — but SAY so, because a
// silently-absent denominator is exactly how the shortfall warning disappeared before.
console.log(` (zone total unavailable: ${e.message})`)
}
console.log(`\nPACKETS-ALL: ${files.length} packet(s) checked, ${files.length - failed.length} passed`)
console.log(` zones covered : ${zones.length}${totalZones ? ` of ${totalZones}` : ''} — ${zones.join(', ')}`)
if (totalZones && zones.length < totalZones) {
console.log(` ⚠ ${totalZones - zones.length} zone(s) have NO packet here and were not rendered by`)
console.log(' anything. Generate another with: bun run packet:ci (one zone, committed fixture,')
console.log(' no network), or ONLY=<zone> node library/scripts/posters/packet.mjs.')
// ★ COUNT AND LIST FROM ONE SET, derived the same way. My first version printed
// `totalZones - zones.length` next to a list built by filtering zoneIds, and they disagreed on sight:
// "21 zone(s)" above a list of 26. zones[] holds every FILE stem, which includes things that are not
// zones at all — the four fixture-* packets and the `<zone>.frontier` ?list= variant — so it overcounts
// coverage against a real-zone denominator. Two numbers describing one quantity, computed two ways, is
// how a report contradicts itself in public; the derived list is now the single source and the count
// is just its length.
const covered = (zoneIds || []).filter((z) => zones.includes(z))
const missing = (zoneIds || []).filter((z) => !zones.includes(z))
const extras = zones.filter((z) => zoneIds && !zoneIds.includes(z))
console.log(` zones covered : ${totalZones ? `${covered.length} of ${totalZones}` : zones.length}`
+ ` — ${(totalZones ? covered : zones).join(', ') || '(none)'}`)
// The fixture and ?list= packets are real coverage of the RENDERER and no coverage at all of a zone's
// data shape. Reported separately rather than folded into the numerator, which is what made the old
// line read "7 of 28" when only 2 real zones had ever been rendered.
if (extras.length) {
console.log(` also checked : ${extras.length} non-zone packet(s) — ${extras.join(', ')}`)
console.log(' (fixtures and ?list= variants: they exercise the renderer, not a zone\'s data shape)')
}
if (totalZones && missing.length) {
// ★ NAME THE MISSING ZONES, do not just count them. "26 zone(s) have no packet" tells a reader the
// size of the hole; it does not tell them what to type, so nobody ever closed it. The list is the
// actionable form — and it is what surfaced `deaf` as unreachable rather than merely ungenerated.
console.log(` ⚠ ${missing.length} zone(s) have NO packet here and were not rendered by`)
console.log(` anything: ${missing.join(', ')}`)
console.log(' Generate all of them (committed fixture, NO network):')
console.log(' node internal/build-packet-fixtures.mjs # the fixture zones')
console.log(' ONLY=<zone> node library/scripts/posters/packet.mjs')
}
if (failed.length) {
console.error(`PACKETS-ALL: FAIL — ${failed.length} packet(s): ${failed.join(', ')}`)
process.exit(1)
}
console.log(`PACKETS-ALL: PASS — every generated packet renders, across ${zones.length} zone(s) `
+ 'and every locale present. This does NOT cover zones no one generated.')
console.log(`PACKETS-ALL: PASS — every generated packet renders, across `
+ `${totalZones ? `${covered.length} of ${totalZones}` : zones.length} zone(s) and every locale `
+ `present.${missing.length ? ` This does NOT cover the ${missing.length} zone(s) named above.` : ''}`)

View file

@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* check-setup-order.mjs — code that RUNS during setup must not be written below what it reads.
*
* Two arms, one root cause: a line's POSITION in a setup block is not the same as the MOMENT it
* runs, and both defects below shipped because the author read the file top-to-bottom and assumed
* the runtime would too. Both were found in the Driver's own console, in a gallery tab, on code
* that built clean, parsed clean and passed every other gate in this tree.
*
* ── ARM A: IMMEDIATE-TDZ ──────────────────────────────────────────────────────────────────────
* research-map.vue:2427 — `ReferenceError: Cannot access '_lastFitKey' before initialization`,
* and the whole map blanked, because setup threw.
*
* `watch(src, cb, { immediate: true })` runs cb DURING setup, at the line the watch() sits on.
* That watcher called applyFitSlugs(), a `function` declaration 350 lines further down — which is
* hoisted, so the CALL resolved fine. Its constants were not: `let _lastFitKey` and seven
* `const FIT_*` above it are lexical, still in their temporal dead zone, and the first read threw.
*
* THE HOISTING ASYMMETRY IS THE WHOLE TRAP. A hoisted function is a promise that the NAME
* resolves early, not that its BODY can run early. So the dangerous edge is not "does the watcher
* read something below it" — that is easy to see — it is "does the watcher CALL something that
* reads something below it", one level of indirection away, and invisible at both ends: the
* watcher looks like it only touches the store, and the function looks like it only touches
* constants that are obviously right there beside it.
*
* ── ARM B: HOOK-AFTER-AWAIT ───────────────────────────────────────────────────────────────────
* DashboardMapPanel.vue:1233 — `onBeforeUnmount is called when there is no active component
* instance` (a warning, which is why it survived), and the cleanup silently never ran.
*
* Every lifecycle registrar closes over Vue's CURRENT INSTANCE, which exists only while setup is
* on the stack. `onMounted(async () => { await initializeMap(); ...; onBeforeUnmount(teardown) })`
* resumes after a microtask, with no current instance — so the registration is dropped and a
* ResizeObserver on document.body plus a window 'resize' listener outlive the component. The
* failure mode is the worst kind: nothing throws, the feature works, and the leak only shows up
* as a studio that gets slower the more tabs the reader has visited.
*
* FIX SHAPE, not just "move the line": the teardown cannot live next to the code that creates the
* thing, because that code is past the await. Declare the handle at setup scope and tear it down
* in one hook registered during setup — which is the pattern the same file already used for
* containerRO and attribRO, three lines away from the one that got it wrong.
*
* Both arms STRIP COMMENTS FIRST. This tree has been bitten five times by a check satisfied by a
* comment quoting the code it was looking for, and this file's own header names both symbols.
*
* USAGE: node internal/check-setup-order.mjs [--poison]
* EXIT: 0 = clean, 1 = a defect, 3 = the self-test failed (the gate cannot be trusted).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { resolve, dirname, relative, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const POISON = process.argv.includes('--poison')
const HOOKS = [
'onMounted', 'onBeforeMount', 'onBeforeUnmount', 'onUnmounted', 'onUpdated', 'onBeforeUpdate',
'onActivated', 'onDeactivated', 'onErrorCaptured', 'onRenderTracked', 'onRenderTriggered',
'onServerPrefetch',
]
const HOOK_RE = new RegExp('\\b(' + HOOKS.join('|') + ')\\s*\\(', 'g')
/* Blank out comments and string bodies in place, so every index stays where it was. */
const blank = (m) => m.replace(/[^\n]/g, ' ')
const stripComments = (src) => src
.replace(/<!--[\s\S]*?-->/g, blank)
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/(^|[^:\\'"`/])\/\/[^\n]*/g, (m, p) => p + ' '.repeat(m.length - p.length))
/* Blank the TEXT of every literal while keeping `${...}` interpolations, which are code.
* One state machine rather than three regexes, because the three quote styles nest: version 2 of
* this gate flagged four data-tables.vue files for "ensure() reads `table`" when the only `table`
* in ensure() is the word in `new Error(`table not found (${r.status})`)`. Prose in a template
* literal is exactly as inert as prose in a comment, and a regex cannot tell them apart. */
const stripStrings = (src) => {
const out = src.split('')
const stack = []
let i = 0
const blankAt = (n) => { if (src[n] !== '\n') out[n] = ' ' }
while (i < src.length) {
const c = src[i]
const top = stack[stack.length - 1]
if (top && (top.kind === 'sq' || top.kind === 'dq')) {
if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue }
if ((c === "'" && top.kind === 'sq') || (c === '"' && top.kind === 'dq') || c === '\n') { stack.pop(); i++; continue }
blankAt(i); i++; continue
}
if (top && top.kind === 'tpl') {
if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue }
if (c === '`') { stack.pop(); i++; continue }
if (c === '$' && src[i + 1] === '{') { stack.push({ kind: 'expr', depth: 0 }); i += 2; continue }
blankAt(i); i++; continue
}
if (c === "'") { stack.push({ kind: 'sq' }); i++; continue }
if (c === '"') { stack.push({ kind: 'dq' }); i++; continue }
if (c === '`') { stack.push({ kind: 'tpl' }); i++; continue }
if (top && top.kind === 'expr') {
if (c === '{') top.depth++
else if (c === '}') { if (top.depth === 0) { stack.pop(); i++; continue } top.depth-- }
}
i++
}
return out.join('')
}
/* Only the <script> half of an SFC can run at setup time; the template is a different phase. */
function scriptOf(src, file) {
if (!file.endsWith('.vue')) return src
const out = src.replace(/[^\n]/g, ' ').split('')
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g
let m
while ((m = re.exec(src))) {
for (let i = m.index + m[0].indexOf('>') + 1; i < m.index + m[0].length - 9; i++) out[i] = src[i]
}
return out.join('')
}
/* Brace-match forward from the first `{` at or after `from`. Returns [open, close] or null. */
function braceSpan(src, from) {
const open = src.indexOf('{', from)
if (open < 0) return null
let d = 0
for (let i = open; i < src.length; i++) {
if (src[i] === '{') d++
else if (src[i] === '}' && --d === 0) return [open, i]
}
return null
}
const lineOf = (src, i) => src.slice(0, i).split('\n').length
const namesInPattern = (pat) => String(pat)
.replace(/[{}[\]]/g, ' ')
.split(',')
.map((s) => s.replace(/:[\s\S]*$/, '').replace(/=[\s\S]*$/, '').replace(/\.\.\./, '').trim())
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n))
/* Names a span DECLARES for itself: any of these resolves locally, so reading one says nothing
* about a binding further down the file. Version 3 of this gate flagged charts.vue for "load()
* reads byCountry, declared at line 354" when load() destructures its OWN `byCountry` on its
* second line — a read of a shadowing local, 220 lines away from the const it appears to name.
* Shadowing is the normal case in this tree (short names, small helpers), not an exotic one. */
function localNames(span) {
const out = new Set()
const add = (pat) => { for (const n of namesInPattern(pat)) out.add(n) }
let m
const decl = /\b(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[A-Za-z_$][\w$]*)/g
while ((m = decl.exec(span))) add(m[1])
const params = /\bfunction\s*[A-Za-z_$]?[\w$]*\s*\(([^)]*)\)|\(([^)]*)\)\s*=>|\bcatch\s*\(([^)]*)\)/g
while ((m = params.exec(span))) add(m[1] ?? m[2] ?? m[3])
const bare = /\b([A-Za-z_$][\w$]*)\s*=>/g
while ((m = bare.exec(span))) out.add(m[1])
return out
}
/* Identifiers a span READS FROM AN OUTER SCOPE: member names, object keys and anything the span
* declares itself are all removed, because none of them is a binding this scope has to resolve. */
function identsIn(span) {
const local = localNames(span)
const read = stripStrings(span)
.replace(/\.\s*[A-Za-z_$][\w$]*/g, ' ') // r.slug — a property, not a binding
.replace(/\b[A-Za-z_$][\w$]*\s*:/g, ' ') // { padding: … } — a key, not a binding
.match(/\b[A-Za-z_$][\w$]*\b/g) || []
return new Set(read.filter((n) => !local.has(n)))
}
/* Top-level (column-0) lexical declarations, which are the ones a setup block resolves. `var`
* hoists to undefined rather than throwing, so it is a different bug and not this gate's. */
function topLevelLexical(src) {
const out = []
const re = /^(?:const|let)\s+(?:\{([^}]*)\}|\[([^\]]*)\]|([A-Za-z_$][\w$]*))/gm
let m
while ((m = re.exec(src))) {
const names = m[3] ? [m[3]]
: String(m[1] || m[2]).split(',').map((s) => s.replace(/:.*$/, '').replace(/=.*$/, '').replace(/\.\.\./, '').trim())
for (const n of names) if (/^[A-Za-z_$][\w$]*$/.test(n)) out.push({ name: n, at: m.index })
}
return out
}
/* Top-level `function NAME(...) {}` declarations — hoisted, so reachable from an immediate watcher. */
function topLevelFunctions(src) {
const out = new Map()
const re = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/gm
let m
while ((m = re.exec(src))) {
const span = braceSpan(src, m.index)
if (span) out.set(m[1], { at: m.index, body: src.slice(span[0], span[1] + 1) })
}
return out
}
// ── ARM A ────────────────────────────────────────────────────────────────────────────────────────
function armA(code) {
const hits = []
const lexical = topLevelLexical(code)
const fns = topLevelFunctions(code)
const re = /\bimmediate\s*:\s*true\b/g
let m
while ((m = re.exec(code))) {
// Walk back to the watch()/watchEffect() this option belongs to: the nearest one before it.
const before = code.slice(0, m.index)
const start = Math.max(before.lastIndexOf('watch('), before.lastIndexOf('watchEffect('))
if (start < 0) continue
const span = code.slice(start, m.index)
const reads = identsIn(span)
// Declared BELOW the watcher = in its temporal dead zone while the immediate run is on the stack.
for (const d of lexical) {
if (d.at > start && reads.has(d.name)) {
hits.push({ line: lineOf(code, start), why: `immediate watcher reads '${d.name}', declared at line ${lineOf(code, d.at)}` })
}
}
// One level of indirection: a hoisted function it calls, whose own body reads something below.
for (const [name, fn] of fns) {
if (!reads.has(name)) continue
const inner = identsIn(fn.body)
for (const d of lexical) {
if (d.at > start && inner.has(d.name)) {
hits.push({ line: lineOf(code, start), why: `immediate watcher calls ${name}(), which reads '${d.name}', declared at line ${lineOf(code, d.at)}` })
}
}
}
}
return hits
}
// ── ARM B ────────────────────────────────────────────────────────────────────────────────────────
function armB(code) {
const hits = []
const re = /\basync\b/g
let m
while ((m = re.exec(code))) {
const span = braceSpan(code, m.index)
if (!span) continue
const body = code.slice(span[0], span[1] + 1)
const firstAwait = body.search(/\bawait\b/)
if (firstAwait < 0) continue
let h
const hre = new RegExp(HOOK_RE.source, 'g')
while ((h = hre.exec(body))) {
if (h.index > firstAwait) {
hits.push({ line: lineOf(code, span[0] + h.index), why: `${h[1]}() is registered after an await, so it never attaches` })
}
}
}
return hits
}
const run = (src, file) => {
/* Strings are blanked HERE, for both arms. Arm B's first version flagged useMapInstance.js:430 —
* where the only `onBeforeUnmount` on the line is inside the text of a console.error advising a
* caller to use one. A gate that reads prose as code cries wolf, and a crying gate gets ignored. */
const code = stripStrings(stripComments(scriptOf(src, file)))
return [...armA(code).map((h) => ({ ...h, arm: 'IMMEDIATE-TDZ' })), ...armB(code).map((h) => ({ ...h, arm: 'HOOK-AFTER-AWAIT' }))]
}
// ── self-test: both defects as they actually shipped, and the fixes, which must stay quiet ───────
const PLANTED_A = `<script setup>
const mapStore = inject('mapStore')
watch(() => cfg?.value?.externalFilters, (raw) => {
if ('fitSlugs' in raw) applyFitSlugs(raw.fitSlugs)
}, { deep: true, immediate: true })
const FIT_MAX_ZOOM = 5.5
let _lastFitKey = ''
function applyFitSlugs(slugs) {
const key = String(slugs)
if (key === _lastFitKey) return
_lastFitKey = key
fit({ maxZoom: FIT_MAX_ZOOM })
}
</script>`
const PLANTED_B = `<script setup>
onMounted(async () => {
await initializeMap()
const resync = () => sync(map.value)
window.addEventListener('resize', resync)
onBeforeUnmount(() => { window.removeEventListener('resize', resync) })
})
</script>`
/* The same two files after the fix, plus the shapes most likely to be mistaken for the defects:
* a NON-immediate watcher above its constants (legal — it cannot run until they exist), a hook
* registered BEFORE an await, and a comment that names the symbol the arms search for. */
const CLEAN = `<script setup>
console.error('containerRef.value is null - call initializeMap() inside onMounted()')
async function ensure(file) { if (!ok) throw new Error(\`table not found (\${file})\`) }
async function load() { const [pins, byCountry] = await all(); tables.value = { pins, byCountry } }
watch(profileConfig, load, { immediate: true })
const byCountry = computed(() => series(tables.value))
watch(current, (t) => t && ensure(t.file), { immediate: true })
const table = computed(() => cache.value[current.value?.file] || null)
const FIT_MAX_ZOOM = 5.5
let _lastFitKey = ''
watch(() => cfg?.value?.externalFilters, (raw) => {
if ('fitSlugs' in raw) applyFitSlugs(raw.fitSlugs)
}, { deep: true, immediate: true })
watch(() => store.other, () => { later({ maxZoom: LATE_ZOOM }) })
const LATE_ZOOM = 3
function applyFitSlugs(slugs) {
if (String(slugs) === _lastFitKey) return
_lastFitKey = String(slugs)
fit({ maxZoom: FIT_MAX_ZOOM, padding: 70 })
}
let scrollRO = null
onBeforeUnmount(() => { if (scrollRO) scrollRO.disconnect() })
onMounted(async () => {
await initializeMap()
scrollRO = new ResizeObserver(() => sync(map.value))
})
</script>`
const blindA = run(PLANTED_A, 'x.vue').filter((h) => h.arm === 'IMMEDIATE-TDZ').length === 0
const blindB = run(PLANTED_B, 'x.vue').filter((h) => h.arm === 'HOOK-AFTER-AWAIT').length === 0
const noisy = run(CLEAN, 'x.vue')
if (blindA || blindB || noisy.length) {
console.error('SETUP-ORDER: SELF-TEST FAILED — '
+ (blindA ? 'arm A is blind to the shipped TDZ. ' : '')
+ (blindB ? 'arm B is blind to the shipped late hook. ' : '')
+ (noisy.length ? 'false positives on the fixed code: ' + noisy.map((h) => `${h.arm} ${h.why}`).join('; ') : ''))
process.exit(3)
}
if (POISON) { console.log('SETUP-ORDER: self-test passed (both defects caught, both fixes quiet).'); process.exit(0) }
// ── scan ─────────────────────────────────────────────────────────────────────────────────────────
const ROOTS = ['plugin-designs', 'library']
const SKIP = /(^|\/)(node_modules|dist|public|\.git|fixtures)(\/|$)/
function files(dir, acc = []) {
let entries
try { entries = readdirSync(dir) } catch (_) { return acc }
for (const e of entries) {
const p = join(dir, e)
if (SKIP.test(relative(REPO, p))) continue
let st
try { st = statSync(p) } catch (_) { continue }
if (st.isDirectory()) files(p, acc)
else if (/\.(vue|js|mjs)$/.test(e)) acc.push(p)
}
return acc
}
const found = []
for (const root of ROOTS) {
for (const f of files(resolve(REPO, root))) {
let src
try { src = readFileSync(f, 'utf8') } catch (_) { continue }
if (!/\bimmediate\s*:\s*true\b|\basync\b/.test(src)) continue
for (const h of run(src, f)) found.push({ file: relative(REPO, f), ...h })
}
}
if (!found.length) {
console.log('SETUP-ORDER: OK — no immediate watcher reads below itself, and no lifecycle hook is registered after an await.')
process.exit(0)
}
console.error(`SETUP-ORDER: ${found.length} defect(s) —`)
for (const h of found) console.error(` ${h.arm} ${h.file}:${h.line} ${h.why}`)
console.error('\nARM A: move the declaration above the watcher (a hoisted function resolves its NAME early, not its BODY).')
console.error('ARM B: declare the handle at setup scope and tear it down in a hook registered BEFORE the first await.')
process.exit(1)

View file

@ -54,7 +54,7 @@ const BUNDLES = [
'dashboard-plus-grid', 'research-dashboard', 'research-map-plus-grid', 'doxa-research-map', 'people-groups-grid',
'regions-dashboard', 'my-upg-100-list', 'doxa-simple-map', 'cities-internet', 'gospel-resources',
'all-people-groups', 'country-posters', 'doxa-vision-card', 'pplr-data-maps', 'prayer-walk-planner',
'upg-neighbor', 'wall-of-people-groups', 'key-cities',
'upg-neighbor', 'wall-of-people-groups', 'key-cities', 'gospel-internet',
]
const ALLOW = {
'prayer-walk-planner:ENG-PIN': 'keyless by design: always MapLibre, needs no token (index.js header)',

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

@ -526,7 +526,9 @@ const showPullTab = computed(() => uiStore.showPullTab)
const legendMode = computed(() => uiStore.legendMode)
const selectedPeopleGroup = computed(() => uiStore.selectedPeopleGroup)
// APPROXPIN: the other groups on the selected pin's coordinate, from the roster THIS map draws.
const drawnPeopleGroups = inject('normalizedPeopleGroups', null)
const colocation = computed(() => {
if (drawnPeopleGroups?.value?.length) return buildColocationIndex(drawnPeopleGroups.value)
const rows = []
for (const id in (dataStore?.sources || {})) { const src = dataStore.sources[id]; if (src?.features?.length) rows.push(...src.features) }
return buildColocationIndex(rows)

View file

@ -24,6 +24,7 @@ import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useInstance } from '../../composables/usePplrInstance.js'
import LegendProgressBar from './LegendProgressBar.vue'
import ApproxPinNote from './ApproxPinNote.vue'
import { pointOf, nameOf } from '../../utils/colocation.js'
// .stl-export-backdrop is an invisible click-catcher ("captures the next click"), so when it is
// clipped nothing LOOKS wrong — but inside the cap-5 shell contain:layout on .vtl-content limits
// it to the content pane (measured 1024x793 @ (256,107) at 1280x900), so a click on the rail or
@ -126,14 +127,15 @@ const progressData = computed(() => props.progress ?? unref(injectedProgress) ??
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
const stlIsDark = computed(() => (instance?.theme.value || 'dark') === 'dark')
const pinDataStore = inject('dataStore', null)
const drawnPeopleGroups = inject('normalizedPeopleGroups', null)
// A people-group pin is a row (or GeoJSON feature) with a name AND a position. The rows the map draws are
// asked first; the dataStore cache is the fallback (it can hold raw API rows or GeoJSON features).
const isPin = (r) => !!(r && nameOf(r) && pointOf(r))
const showPinNote = computed(() => {
if (props.pinNote !== null) return props.pinNote
if (drawnPeopleGroups?.value?.length) return drawnPeopleGroups.value.slice(0, 20).some(isPin)
const sources = pinDataStore?.sources || {}
for (const id in sources) {
const f = sources[id]?.features?.[0]
const p = f?.properties || {}
if (f?.geometry?.type === 'Point' && (p.name || p.peopleName || p.PeopNameInCountry)) return true
}
for (const id in sources) if ((sources[id]?.features || []).slice(0, 20).some(isPin)) return true
return false
})
// A click on a bar span selects the matching legend row through the SAME handler

View file

@ -0,0 +1,275 @@
<script setup>
/**
* RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
* and a footer link. The DX-life /regions card, made reusable.
*
* WHY THIS IS IN library/. The Driver, verbatim: "let's say we want to reuse that on future plugins.
* We make it a reusable component, parameterized — you can set the region titles wherever you want
* them to be, fill in the countries to whatever you want them to be." The design is the point as much
* as the markup: the 156 lines of CSS below are lifted UNCHANGED from the regions plugin, so a caller
* gets the look, not a lookalike.
*
* ★ IT TAKES DATA, NOT AN API. The plugin version fetched its own regions and owned the layout, which
* is what made it unusable anywhere else. This takes `title`, `summary` and `countries` and emits what
* was clicked. Nothing here knows where regions come from.
*
* ★ THE CHEVRON IS INLINE ON PURPOSE, and this is the one thing a mover has to get right. The plugin
* declared <symbol id="chevron-up"> ONCE in its own template and every card referenced it with
* <use href="#chevron-up">. A shadow-root id is scoped to that root, so the same card mounted anywhere
* else would reference a symbol that does not exist — and an unresolved <use> renders NOTHING, with no
* error. The chevron would simply be missing. The plugin's own comment warns about exactly this class
* for external hrefs ("would 404 SILENTLY and the chevron would simply vanish"); a cross-root id
* reference fails the same way. So the path travels with the component.
*
* ★ RTL NEEDS AN ANCESTOR CARRYING dir, and that is a REQUIREMENT ON THE CONSUMER. The rules below
* include [dir="rtl"] .region-card__icon, ported because a chevron meaning "forward" must mirror in
* Arabic while a disclosure caret meaning "open/closed" must not — and the rtl --open rule is
* load-bearing, since [dir="rtl"] .region-card__icon is specificity 0-2-0 and would otherwise beat the
* 0-1-0 --open modifier and freeze the caret. The regions plugin satisfies this because .rl-root
* carries :dir itself. A new consumer must set dir on an ancestor inside its own shadow root, or the
* mirroring silently will not happen.
*
* ★ EXTRA CONTENT IS A SLOT, NOT A PROP. The regions plugin shows a country-poster wall under each
* card, configured with fourteen PosterWall props. Baking that in would have dragged one plugin's
* feature into every consumer's API. The `extra` slot keeps it where it belongs.
*/
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const props = defineProps({
/** The region's display name. Already localized by the caller. */
title: { type: String, default: '' },
/** The line under the title, e.g. "12 countries · 480 groups". Caller-formatted. */
summary: { type: String, default: '' },
/** [{ name, count, slug }] — `count` is optional and rendered in parentheses when present. */
countries: { type: Array, default: () => [] },
/** The footer link's text, e.g. "View the region dashboard". Omit it to hide the footer. */
footerLabel: { type: String, default: '' },
/**
* OPTIONAL DISCLOSURE ROW, and the card owns it deliberately. The regions plugin needed a
* "View country posters" toggle under each card. The first cut put that row in the consumer's slot
* — which immediately meant copying this component's footer-link look and its rotating caret back
* into the plugin, i.e. re-creating the duplication this extraction exists to remove. A disclosure
* inside a card is a CARD feature; only the revealed CONTENT belongs to the consumer.
* Omit the label to hide the row entirely.
*/
disclosureLabel: { type: String, default: '' },
disclosureOpen: { type: Boolean, default: false },
/** id for aria-controls, so the button and the revealed region are really associated. */
disclosureId: { type: String, default: '' },
})
const emit = defineEmits(['select', 'select-country', 'toggle-disclosure'])
useShadowStyles(
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
// is KEPT as a clamp (var(--spacing-md) / var(--spacing-2xl) are the resolved ends), not frozen at its desktop maximum.
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:var(--spacing-2xs);' +
' padding:var(--spacing-md) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl));' +
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(var(--font-size-xl),_,var(--font-size-3xl)).
// A phone gets the var(--font-size-xl) FLOOR, as theirs does; it only reaches var(--font-size-3xl) past ~1192px of width.
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
// face itself carries the boldness), all-caps to match the source screenshot exactly.
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
// not served with CORS -- cross-origin @font-face silently fails without it, so it cannot be
// loaded here without shipping a font asset this repo doesn't own. Approximated with the
// standard condensed-display-face fallback stack (bold weight + font-stretch:condensed + tight
// tracking), since Bebas Neue's glyphs read as heavy/compressed even at its nominal "400" weight.
// getComputedStyle() on the live card title reads weight 400 / line-height 1.0 (47.68px on a
// 47.69px font-size) / letter-spacing normal / text-transform none -- Bebas Neue's own glyphs
// are what read as bold+condensed+caps-like, DX-life applies NO transform on top of the font.
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
// exactly rather than compounding extra "boldness" the source doesn't have.
'.region-card__title{ margin:0; font-size:clamp(var(--font-size-xl), 4cqw, var(--font-size-3xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; letter-spacing:normal; }' +
// Their link is an <a class="region-card__link | with-icon">, so it carries their `a` rule's
// font-weight: var(--font-weight-medium) = 500 (_globals.scss:132). Ours is a <button>
// because this element has no router, and `font:inherit` gave it 400 — MEASURED against
// their live /regions. Same class of miss as the dashboard back-link.
// The icon spacing is theirs too: .with-icon is inline-flex with align-items:baseline and NO
// gap (composition/_icon.scss:12), and the 1rem separation comes from
// `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px at their 10px root. We had
// the same 10px via `gap`, which looks identical but means their rule would not apply, so
// the mechanism is now theirs: margin on the icon, gap left alone.
// ★ text-align:START, not left. Theirs is a NuxtLink, an inline <a> that shrink-wraps to its
// text; ours must be a <button> because this element changes the embed's subject in place instead
// of navigating a route. A button's UA default is text-align:center, so the port overrode it -- but
// it overrode it with the PHYSICAL `left`, which is identical to `start` in English and its
// opposite in Arabic. MEASURED on live /ar/regions vs ours at dir=rtl: same 246.9px row and the
// same 102.7px of text, their label flush to the row's RIGHT edge (gap 0px, the RTL reading start)
// and ours flush LEFT -- every label stranded 144.2px on the wrong side. Invisible in English,
// which is exactly why it survived a 37-property card diff and an 11-property bullet diff.
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:start;' +
' cursor:pointer; color:inherit; font:inherit; font-weight:500;' +
' display:inline-flex; align-items:baseline; }' +
'.region-card__link:hover{ color:#92b195; }' +
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
// .region-card__icon: DX-life's own <svg class="icon | rotate-90 right"><use href="arrow-
// chevron.svg#chevron-up"></use></svg> -- a REAL chevron path (viewBox 0 0 489.67 289.877),
// not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour,
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
// ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on
// our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the
// element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and
// their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours
// inherits whatever the host sets: 16px by default, so this gap rendered 16px against their
// 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by
// accident. Pinned to the value their root produces, like every other token here.
// No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and
// alignItems — no margin — so the icon's own spacing was never a measured property. It is now.
// (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.)
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' +
' margin-inline-start:10px; }' +
// .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' +
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44:
// h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } }
// and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC
// GLYPHS, so a Russian heading in it leaves the family entirely and lands on whatever condensed
// system face the browser picks. We ship a `ru` locale and had neither the rule nor the font.
// ONLY the headings, exactly as theirs — their .region-stat__value is a <p> and keeps Bebas even in
// Russian, which is their behaviour and so is ours.
// BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`,
// and a host page embedding this element may set either. A selector that matched only one would
// work on their site and fail on ours, or the reverse, depending on who set the attribute.
'[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,' +
'[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl)
'.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' +
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
// (10px / var(--spacing-xl)). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
// bullet has somewhere to sit without stealing width from the text column.
// padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces
// put the indent on the wrong side in Arabic, where the bullets hang off the right edge.
'.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' +
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
// Their country entries are bare <NuxtLink>s inside .countries-grid li — no class of their
// own — so they take their `a` rule: color var(--color-brand-light) = $color-green-400 =
// #4e594f, font-weight medium. MEASURED on their live /regions: rgb(78, 89, 79). Ours used
// var(--dx-text), which resolves to rgb(42, 51, 42) — the body ink, not their link ink.
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
' text-align:start; cursor:pointer; color:#4e594f; font:inherit; font-size:var(--font-size-md);' +
' font-weight:500; width:100%; position:relative; }' +
// inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the
// RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured
// at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang.
// ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list
// marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start
// edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their
// .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list
// bullets (global ul padding + ::before dot)").
// I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong
// in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the
// image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their
// negative margin. Not a near-miss — a different bullet.
// The rule is a double-quoted JS string because the data URI contains single quotes.
".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" +
" line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" +
" background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" +
// Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this
// CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in.
'[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }' +
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (var(--spacing-lg)),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~var(--font-size-md) at
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
// The toggle row and the wall it opens. The caret rotates rather than swapping glyphs,
// which is what makes open/closed legible at a glance.
'.region-card__posters-toggle{ margin-top:0; }' +
'.region-card__icon--open{ transform:rotate(180deg); }' +
// RTL MIRRORING, ported from their utilities/_shapes.scss:
// .rotate-90 { transform:rotate(90deg); [dir="rtl"] & { transform:rotate(-90deg); } }
// Their .rotate-180 has NO rtl variant, and that distinction is the whole rule: a chevron that
// means "forward" must mirror, a disclosure caret that means "open/closed" must not. MEASURED
// on live doxa.life: .region-card__link svg is matrix(0,1,-1,0,0,0) at /regions and
// matrix(0,-1,1,0,0,0) at /ar/regions — ours stayed at +90deg in Arabic, so the chevron sat on
// the correct side pointing the WRONG WAY. Found by looking at the Arabic screenshot.
// The selector works inside our shadow root because .rl-root carries :dir itself.
'[dir="rtl"] .region-card__icon{ transform:rotate(-90deg); }' +
// ...and this one is load-bearing: [dir="rtl"] .region-card__icon is specificity 0-2-0, which
// would otherwise beat the 0-1-0 --open modifier and freeze the poster caret in Arabic.
'[dir="rtl"] .region-card__icon--open{ transform:rotate(180deg); }' +
'.region-card__posters{ margin-top:13.3px; }' +
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:var(--spacing-lg);' +
' padding:0 clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-lg); }' +
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
' cursor:pointer; font:inherit; font-size:var(--font-size-md); font-weight:500; color:var(--dx-link);' +
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',
'regions-list'
, 'region-section-card')
</script>
<template>
<div class="region-card">
<div class="region-card__header">
<h2 class="region-card__title">
<button type="button" class="region-card__link" @click="emit('select')">
{{ title }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</h2>
<p v-if="summary" class="region-card__summary">{{ summary }}</p>
</div>
<div class="region-card__body">
<ul class="countries-grid">
<li v-for="c in countries" :key="c.slug || c.name">
<button type="button" class="country-link" @click="emit('select-country', c)">
{{ c.name }}<span v-if="c.count != null" class="country-link__count"> ({{ c.count }})</span>
</button>
</li>
</ul>
</div>
<p v-if="footerLabel" class="region-card__footer">
<button type="button" class="region-card__footer-link" @click="emit('select')">
{{ footerLabel }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</p>
<p v-if="disclosureLabel" class="region-card__footer region-card__posters-toggle">
<button
type="button"
class="region-card__footer-link"
:aria-expanded="disclosureOpen ? 'true' : 'false'"
:aria-controls="disclosureId || undefined"
@click="emit('toggle-disclosure')"
>
{{ disclosureLabel }}
<svg class="region-card__icon" :class="{ 'region-card__icon--open': disclosureOpen }" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</p>
<!-- Only the revealed CONTENT is the consumer's. The regions plugin puts its PosterWall here. -->
<div v-if="disclosureOpen" :id="disclosureId || undefined" class="region-card__posters">
<slot name="extra" />
</div>
</div>
</template>

View file

@ -22,7 +22,7 @@ import { ENGAGEMENT_COLORS } from '../../colors/engagement.js'
import { ADOPTION_COLORS } from '../../colors/adoption.js'
import { getLanguageFamilyColor } from '../../colors/language-family.js'
import { getColorStrategy, COLOR_MODES } from '../../colors/_registry.js'
import { PALETTE as RELIGION_COLORS, RELIGION_FAMILIES, getReligionFamily } from '../../colors/religion.js'
import { PALETTE as RELIGION_COLORS, getReligionFamily, religionFamilyLabel } from '../../colors/religion.js'
import { useLanguageFamilyLegendData } from './useLanguageFamilyLegendData.js'
const COLUMNS_SIMPLE = [] // e.g. affinity-blocs — no numeric columns
@ -319,7 +319,11 @@ export function useLegendData(legendTypeRef, opts = {}) {
})
return order.map(fam => ({
key: fam,
label: RELIGION_FAMILIES[fam] || 'Unknown',
// REGREL: the LABEL is localized, the VALUE is not. `key` and `filterKey` below stay the
// canonical family letter, which is what the filter and the map compare on; only this
// display string follows the reader's language. RELIGION_FAMILIES is no longer imported here:
// religionFamilyLabel() reads it internally, so this file's only use of it was this label.
label: religionFamilyLabel(fam) || 'Unknown',
color: RELIGION_COLORS[fam] || '#94a3b8',
count: familyAggregates[fam].count,
population: familyAggregates[fam].population,

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

@ -77,6 +77,30 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// What this export calls itself, and who to credit for the data. Defaults keep every existing
// caller's file identical to the byte.
exportLabel = 'Research Dashboard', sources = '',
// ★ FOUR OPTIONS ADDED BY THE KIT'S FIRST REAL CONSUMER (go-int, 2026-09-27). Each one is a
// pray-tools assumption this file was making silently; each default is exactly what
// research-dashboard already did, so Big Picture's file is unchanged to the byte. A second
// consumer is the only thing that finds these — I could not have told you they were assumptions
// while there was one caller.
//
// columns: the EXACT column set and order to export. Default = today's behaviour (the canonical
// EXPORT_FIELDS order, then any other key the API returned). go-int's spec asks for the VISIBLE
// columns, and projecting rows before handing them over — its workaround — loses the ordering
// this file exists to impose.
columns = null,
// wide: fetch the ALL-FIELDS record before writing. Default true = today's behaviour. A
// static-table dashboard has nothing to widen FROM, so the fetch is a wasted network call that
// would also pollute its rows with pray-tools fields.
wide = true,
// filename / hookPrefix: what the downloaded file is called, and the window hooks a browser gate
// reads. Defaults are the existing literals, and that matters — __rdash* names are read
// elsewhere in this tree, so changing the default would break checks that have nothing to do
// with this landing.
filename = 'research-dashboard.csv', hookPrefix = '__rdash',
// keyOf: how a row identifies itself, for matching a narrow row to its wide twin and for reading
// the selection. Default = `slug || id`, which is what this file assumed. config.data.primaryKey
// is the config-level spelling of the same thing; go-int flagged the pair, and they should agree.
keyOf = (r) => r && (r.slug || r.id),
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
// slug -> wide API record, filled by the lazy all-fields fetch below.
@ -92,6 +116,9 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
*/
function fetchWideRows() {
// Nothing to widen FROM without a source, and nothing asked for when wide is off. Returning a
// resolved null rather than throwing keeps exportCsvAsync's single call site unchanged.
if (!wide || !(dataOpts && dataOpts.source)) return Promise.resolve(null)
if (widePromise) return widePromise
widePromise = (async () => {
const sourceId = dataOpts.source || sourcesConfig.activeSource
@ -106,7 +133,7 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// Only the groups of this page's people-group LIST (library/lists/) are exported.
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
const map = new Map()
for (const r of raw) { const k = r && (r.slug || r.id); if (k) map.set(k, r) }
for (const r of raw) { const k = keyOf(r); if (k) map.set(k, r) }
wideStore = map
return map
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
@ -116,7 +143,7 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
function widen(r) {
if (!wideStore) return r
const w = wideStore.get(r && (r.slug || r.id))
const w = wideStore.get(keyOf(r))
return w ? { ...r, ...w } : r
}
@ -164,8 +191,14 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
// people → engagement → resources), then any key the API returned that the list doesn't name.
// Union across rows, because a null-valued field can be absent on an individual record.
// An explicit `columns` is taken AS GIVEN — order included, and without filtering to keys that
// happen to be present. A column the reader can see must appear in the file even when every row
// in this particular scope has it empty; dropping it would make the export's shape depend on the
// filter, which is the opposite of what a column picker promises.
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
const cols = [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
const cols = Array.isArray(columns) && columns.length
? columns.slice()
: [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
// columns below are built from the API record via flatten()/cell() and are untouched, because
@ -219,7 +252,7 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// so it would have looked correct and been wrong — the quiet kind of breakage a positional change
// leaves behind.
const fields = (csv.split('\r\n')[0] || '').split(',').length
try { if (typeof window !== 'undefined') window.__rdashLastCsv = csv } catch (_) { /* verify hook */ }
try { if (typeof window !== 'undefined') window[hookPrefix + 'LastCsv'] = csv } catch (_) { /* verify hook */ }
let clipboard = false, downloaded = false
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
try { if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) { /* no clipboard */ }
@ -227,12 +260,12 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
try {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = 'research-dashboard.csv'; a.style.display = 'none'
const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'
document.body.appendChild(a); a.click(); downloaded = true
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
} catch (_) { /* download blocked — clipboard above already has it */ }
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
try { window.__rdashExportStatus = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
try { window[hookPrefix + 'ExportStatus'] = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
}

View file

@ -90,6 +90,10 @@ import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
// existing layout survives the change; nothing writes to it. Delete it once no reader can plausibly
// still be carrying one.
const LEGACY_SHARED_KEY = 'rd-dashboard-layout'
// The default key, named once so the legacy read-through can ask "am I the default?" instead of
// re-typing the literal — a fallback comparing against a copy of its own default is a fallback that
// silently stops matching the day someone bumps the version suffix.
const DEFAULT_STORAGE_KEY = 'rd-dashboard-layout-v6'
// Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue.
// These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them.
@ -170,7 +174,7 @@ export function useDashboardLayout(options = {}) {
// in the commit that first trips over it.
// The caller passes its own id (the shell uses config.id). The default keeps a caller that
// passes nothing working, and still namespaces it away from any caller that does.
storageKey = 'rd-dashboard-layout-v6',
storageKey = DEFAULT_STORAGE_KEY,
} = options
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }
@ -271,7 +275,14 @@ export function useDashboardLayout(options = {}) {
// every reader's saved layout. Read-only and never written back: the new key is written on
// the next drag-end, and the old value is simply left where it is rather than migrated and
// deleted — a migration that also deletes is one that cannot be rolled back.
const raw = localStorage.getItem(storageKey) || localStorage.getItem(LEGACY_SHARED_KEY)
// ★ ONLY FOR THE DEFAULT KEY (go-int, 2026-09-27) — and this was my own regression from kit
// landing 2. I added the read-through so namespacing would not discard a reader's saved
// layout, and applied it unconditionally: so a caller that passes its OWN storageKey, which
// is the entire point of namespacing, still inherited Big Picture's rail widths on first
// load. The migration meant to protect one dashboard's layout was handing it to every other
// dashboard. A fallback that ignores the key it is falling back FROM is not a fallback.
const legacyOk = storageKey === DEFAULT_STORAGE_KEY
const raw = localStorage.getItem(storageKey) || (legacyOk ? localStorage.getItem(LEGACY_SHARED_KEY) : null)
if (!raw) return false
const s = JSON.parse(raw)
if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW)
@ -333,6 +344,18 @@ export function useDashboardLayout(options = {}) {
const startX = e.clientX
const startFacet = facetW.value
const startRail = railW.value
// ★ THE X DELTA HAS A READING DIRECTION (go-int, 2026-09-27). Under dir=rtl the grid mirrors —
// the facet rail sits on the RIGHT — so dragging its handle toward the map INCREASES clientX
// where in LTR it decreases it. The maths below was written once, in LTR, and the result in
// Arabic is that dragging the facet handle shrinks the CHARTS rail: both handles fight, and
// neither does what the cursor says. This reaches Big Picture in ar today, not only go-int.
// Read per drag rather than cached: a host can flip dir between gestures (our own composites
// set it on the custom-element host from the runtime locale), and a cached sign would be wrong
// for the rest of the session.
const rtl = (() => {
try { return getComputedStyle(unref(shellRef)).direction === 'rtl' } catch (_) { return false }
})()
const xSign = rtl ? -1 : 1
const handleEl = e.currentTarget
// ── PAGE-SCROLL AXIS SWITCH (BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING) ──────────────────────
// In page-scroll mode (research-dashboard.vue's pageScroll / dashboard-plus-grid.vue) the
@ -405,11 +428,11 @@ export function useDashboardLayout(options = {}) {
pending = null
if (axis === 'facet') {
// left facet edge → dragging right widens the facet column
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
facetW.value = Math.round(clamp(startFacet + xSign * (ev.clientX - startX), clamps.facetW))
customized.value = true
} else if (axis === 'rail') {
// right rail's LEFT edge → dragging left widens the rail column
railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW))
railW.value = Math.round(clamp(startRail - xSign * (ev.clientX - startX), clamps.railW))
customized.value = true
} else if (isTablePageScroll) {
// page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is
@ -519,7 +542,16 @@ export function useDashboardLayout(options = {}) {
const K = e.key
let dir = 0
if (axis === 'table' || axis === 'shell') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 }
else { if (K === 'ArrowLeft') dir = -1; else if (K === 'ArrowRight') dir = 1 }
else {
// ★ AND THE ARROWS FOLLOW THE SAME SIGN (go-int). The pointer path now mirrors under rtl; if
// the keyboard did not, the two input methods would disagree about which way widens — and a
// keyboard user in Arabic would find the arrows inverted relative to the drag they just
// watched. aria-valuenow reports the same ref for both, so a disagreement here would also
// make the announced value move the wrong way.
let kSign = 1
try { if (getComputedStyle(unref(shellRef)).direction === 'rtl') kSign = -1 } catch (_) { /* no element */ }
if (K === 'ArrowLeft') dir = -1 * kSign; else if (K === 'ArrowRight') dir = 1 * kSign
}
if (dir) { e.preventDefault(); stepResize(axis, dir); return }
if (K === 'Home' || K === 'End') {
e.preventDefault()

View file

@ -0,0 +1,100 @@
/**
* useDashboardSearch.js — the ONE free-text search the whole dashboard shares.
*
* ★ EXTRACTED FROM useDashboardShell (dashboard-kit landing 4a) BECAUSE EIGHT THINGS IMPORT IT.
* These three functions were exported from the shell, and every panel reached into the shell to get
* them: the table, the map panel, the charts rail, the facet rail, the research filters, the top bar,
* the profile and the config. That is not a shell concern; it is a module in its own right that
* happened to live inside one.
*
* It also unblocks the facet rail. Landing 4 was supposed to move the rail, and the rail imports
* these helpers — so moving the rail first would have meant a library component importing a plugin
* composable, which check:no-cross-plugin-imports rightly refuses. The dependency had to come first;
* SPEC-DASHBOARD-KIT section 8 did not notice that, and now says so.
*
* Nothing here is domain-specific: the accessors come from `config.facets`, `config.columns` and
* `config.searchKeys`, and the matching comes from the shared @map/components/search/fuzzy.js. A
* dashboard with different facets gets its own search for free.
*
* Bodies are verbatim from the shell — this is a move, not a rewrite.
*/
import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
// tiny lodash-free mapValues: { k: v } -> { k: fn(v, k) }
const mapValues = (obj, fn) =>
Object.fromEntries(Object.entries(obj || {}).map(([k, v]) => [k, fn(v, k)]))
/**
* Build the free-text search accessors for a dashboard config (header-1 search bar).
* config.searchKeys (facet keys) → those facet accessors; else the string columns' accessors.
* ONE declaration reused by the shell (KPIs) AND every panel (table/map/charts/facets) so the
* searchable text can never drift from what the panels show, and the substring match is not
* copy-pasted into each panel (kills the very duplication DOXA-LIB-2 removed).
*/
export function buildSearchAccessors(config) {
const FACET_ACC = mapValues(config.facets, (f) => f.get)
const COL_ACC = Object.fromEntries((config.columns || []).map((c) => [c.key, c.get]))
const keys = Array.isArray(config.searchKeys) ? config.searchKeys : null
if (keys) return keys.map((k) => FACET_ACC[k] || COL_ACC[k]).filter((fn) => typeof fn === 'function')
return (config.columns || []).filter((c) => c.type !== 'num' && typeof c.get === 'function').map((c) => c.get)
}
/**
* Reusable predicate: does `row` match the bus's current free-text search term?
* Returns true when there is no term (identical to pre-search behaviour). `accessors` is the
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
*/
export function makeSearchMatcher(bus, accessors) {
// ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must
// still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with
// { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
// search tolerates typos. This matcher was a plain .includes() -- the one search surface that
// silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
// It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the
// grid's Fuse uses -- with the same DX-life options, so every search surface agrees.
// Row-level predicates for the TIERED contract (see fuzzy.js exactMatches): the caller
// (filterRowsBySearch below) runs the exact tier over the whole row set first and falls back
// to the fuzzy tier only when exact yields nothing, so an exact term agrees with the facet.
const test = (row, pred) => {
const needle = (bus?.searchTerm?.value || '').trim()
if (!needle) return true
for (const acc of accessors) {
const v = acc(row)
if (v != null && pred(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true
}
return false
}
const matcher = (row) => test(row, fuzzyMatches)
matcher.exact = (row) => test(row, exactMatches)
matcher.active = () => !!(bus?.searchTerm?.value || '').trim()
matcher.term = () => (bus?.searchTerm?.value || '').trim()
matcher.accessors = accessors
return matcher
}
/**
* Apply the TIERED search to a row set:
* 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects.
* 2. facet-VALUE resolution (Driver: "Austrian or Canadian... a selection of that country --
* the table under the map shows the result for that country"): the term is resolved by
* whole-value edit distance against each accessor's DISTINCT values, and the rows returned
* are those carrying a resolved value -- byte-identical to picking it as a filter.
* 3. the generic windowed fuzzy rescue, only when nothing resolves.
*/
export function filterRowsBySearch(rows, matcher) {
if (!matcher.active()) return rows
const exact = rows.filter(matcher.exact)
if (exact.length) return exact
const term = matcher.term()
// ONE pool across every accessor's distinct values, so the GLOBAL best match wins (see
// resolveFuzzyValues: ties at the minimal distance stay, everything farther drops --
// 'Canadian' resolves to Canada, not to 47 rows of <=3-edit neighbours across facets).
const distinct = new Set()
for (const acc of matcher.accessors) for (const r of rows) { const v = acc(r); if (v != null && v !== '') distinct.add(String(v)) }
const winners = new Set(resolveFuzzyValues(term, [...distinct], { threshold: 0.4 }))
if (winners.size) {
const resolved = rows.filter((r) => matcher.accessors.some((acc) => { const v = acc(r); return v != null && winners.has(String(v)) }))
if (resolved.length) return resolved
}
return rows.filter(matcher)
}

View file

@ -3,7 +3,7 @@
// Vue 3 Composition API - ES Module with NPM packages
// Import Vue functions from NPM package
import { ref, computed } from 'vue';
import { ref, computed, provide, getCurrentInstance } from 'vue';
import { applyLocalPrayer } from './usePrayedLocally.js';
// Static lookup: language label (e.g. "Telugu") → language family ("Dravidian").
// Used to derive `languageFamily` for pray-tools rows where the upstream returns
@ -136,6 +136,12 @@ export function useMapData(options) {
// Data arrays (will be marked as raw for performance)
const peopleGroups = ref([]);
const normalizedPeopleGroups = ref([]);
// THE ROWS THE MAP DRAWS, offered to the legends below it. research-map and countries-map provided
// this themselves; the prayer map did not, so its legends fell back to the raw API cache, whose field
// names the pin-note co-location index cannot read, and the shared-point sentence and the standing
// pin note never showed there (rega, row 46). Providing it here covers every map built on useMapData;
// a component that also provides it passes this same ref.
if (getCurrentInstance()) provide('normalizedPeopleGroups', normalizedPeopleGroups);
const regionsData = ref(null);
const languageFamiliesData = ref([]);
const doxaRegionsData = ref([]);

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

@ -32,7 +32,10 @@ import langFamilyByLanguage from '../data/langFamilyByLanguage.json'
// Religion search must group by FAMILY (same 10 families the Religion legend shows),
// not by raw sub-type names — otherwise search surfaces 'Animism'/'Romanism' rows
// the legend can't select. Reuse the legend's exact family logic.
import { RELIGION_FAMILIES, getReligionFamily } from '../colors/religion.js'
// RELIGION_FAMILIES is no longer imported: its only use here was the label that now goes through
// religionFamilyLabel(), which reads the canonical map internally. The KEY still comes from
// getReligionFamily, so nothing that is compared or sent changed.
import { getReligionFamily, religionFamilyLabel } from '../colors/religion.js'
// ── Result category ordering by active-tab context (Round 16) ────────────────
// The search bar is unified — every category is always searched — but the
@ -319,11 +322,19 @@ function buildAggregates(entries) {
// SAME extraction the legend uses (getReligionFamily on the religion code),
// so every search result corresponds to a selectable Religion-legend row.
// The raw sub-type names ('Animism') still MATCH via religionHay, but collapse
// into their family row. label = family name so selection can resolve the
// family letter back (see research-map onGeocoderAggregateResult religion branch).
// into their family row.
// ✗ THIS COMMENT USED TO SAY: "label = family name so selection can resolve the family letter back
// (see research-map onGeocoderAggregateResult religion branch)". It was WRONG, and wrong in the
// direction that forbids the fix below: it presented an English label as a REQUIREMENT of
// selection. Measured 2026-09-27 — onGeocoderAggregateResult does not exist anywhere in this repo
// (three comments name it, nothing defines it), and selection resolves by VALUE: aggregate() emits
// `key` and `label` as SEPARATE fields (:262-263) with key from getReligionFamily, and the consumer
// reads detail.expectedValue, never the label (country-posters/countries-map.vue:1611). So the label
// is display-only and safe to localize. A stale comment that argues against a correct change is
// worse than no comment: the next reader either reverts it or refuses the next one like it.
religions: aggregate(
(e) => getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || ''),
(e) => RELIGION_FAMILIES[getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || '')] || 'Unknown'
(e) => religionFamilyLabel(getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || '')) || 'Unknown'
),
// WAGF / DOXA regions (doxaRegion) — keyed by the region value so a search
// for "Asia" collapses every pin in that region into one row. The click

View file

@ -19,6 +19,9 @@
* NARROWS scope — panels render excluded context by FADING, never removing.
* - selectionSet : explicit set of slugs (table checkbox / map lasso / chart pick).
* MARKS individuals — panels render with RING/OUTLINE.
* - hoverKey : the ONE record the pointer is over, or null (D3).
* POINTS at one — panels may fade or outline; they may not fly, select or
* filter. Hover is not a commitment, so nothing irreversible may follow it.
*
* Export priority (later phases): selectionSet (if non-empty) > filterSet result > full dataset.
* Every panel is both publisher and subscriber. All client-side over one in-memory row store
@ -99,6 +102,24 @@ export function createSelectionBus() {
// facet filterSet: it applies a substring/label match across the config's `searchKeys` in
// useDashboardShell.filteredRows. Empty string = no text predicate (identical to before).
const searchTerm = ref('')
// ── hoverKey (SPEC-DASHBOARD-KIT D3) ────────────────────────────────────────────────────────
// THE THIRD COORDINATED CHANNEL, and the only one this bus was missing. filterSet narrows scope
// and selectionSet marks individuals; HOVER points at one record without committing to it, so a
// reader can sweep a table and watch the map answer. kepler.gl treats hover as a configured
// interaction alongside filters for the same reason.
//
// ★ IT IS A PLAIN ref AND NOT A SET, AND THAT IS THE WHOLE DESIGN. Hover fires on every
// mousemove across a 2,107-row table. selectionSet had to be a plain Set with a version counter
// precisely because a reactive collection fires per element (see the performance contract
// above); a single ref has none of that problem — one dependency, one write, no iteration. Any
// future "hover several things" must be argued for against that, not assumed.
//
// ★ HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. It does not enter selectionSet, does not
// bump selectionVersion, and therefore does not move the camera: useSelectionCamera watches the
// version, and a camera that chased the pointer across a table would be the resize-strobe bug
// wearing a new hat, on a Driver who is photosensitive. A panel may fade or outline on hover; it
// may not fly, select or filter.
const hoverKey = ref(null)
const subs = new Map() // event -> Set<cb>
function on(event, cb) {
@ -156,6 +177,18 @@ export function createSelectionBus() {
}
// ── free-text search (header-1) ──
/** Point at one record, or `null` for nothing. The value is whatever the profile's primary key
* is (`config.data.primaryKey`, a slug here) so every panel is pointing at the same thing.
* Deliberately NOT debounced in the bus: what a hover costs depends on what the panel does with
* it, so the panel that repaints owns that decision. The bus's job is to be cheap enough that
* it never needs one. */
function setHover(key) {
const next = key === undefined ? null : key
if (hoverKey.value === next) return // a no-op must not wake every panel watching the ref
hoverKey.value = next
emit('hover', next)
}
function setSearch(term) {
searchTerm.value = term == null ? '' : String(term)
emit('search', searchTerm.value)
@ -220,8 +253,8 @@ export function createSelectionBus() {
}
return {
filterSet, selectionSet, selectionVersion, searchTerm, on, emit,
setFilter, clearFilters, setSearch, configureHierarchy,
filterSet, selectionSet, selectionVersion, searchTerm, hoverKey, on, emit,
setFilter, clearFilters, setSearch, setHover, configureHierarchy,
toggleSelect, clearSelection, selectMany, deselectMany, setSelection,
toggleFilterValue, facetMatches, isFilterActive, rowMatchesFilters,
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "أقل من 25% متصلون",
"nodata": "لا توجد بيانات",
"partial": "25–59% متصلون",
"yes": "60% أو أكثر متصلون"
},
"centre": {
"aria": "عرض",
"map": "الخريطة",
"tree": "شجرة اللغات"
},
"charts": {
"accessByBand": "الوصول بلغتهم، حسب استخدام الإنترنت في البلد",
"aria": "رسوم بيانية",
"bandCaveat": "استخدام الإنترنت رقم البلد (البنك الدولي)، وليس رقم المجموعة أبدًا.",
"groups": "المجموعات",
"groupsN": "{n} مجموعة",
"measure": "العدّ",
"messagingTop": "تطبيق المراسلة الأول، حسب البلدان ضمن النطاق",
"notMeasured": "غير مقيس",
"people": "الأشخاص",
"peopleN": "{n} شخص",
"socialTop": "منصة التواصل الأولى، حسب البلدان ضمن النطاق",
"unitCountries": "بلدان",
"unitGroups": "مجموعة عرقية",
"unitPeople": "شخص",
"zeroByBand": "لا شيء بلغتهم، حسب استخدام الإنترنت في البلد",
"zeroCaveat": "لكل واحدة منها مدخل: لغة جسر، أو لغة مرشّحة، أو جسر بلغة الإشارة."
},
"col": {
"access": "بلغتهم",
"band": "فئة الإنترنت",
"bridgeTraining": "تدريب (لغة الجسر)",
"country": "البلد",
"deafPublisher": "ناشر بلغة الإشارة",
"internet": "البلد متصل",
"internetYear": "السنة",
"language": "اللغة",
"messaging": "تطبيق المراسلة الأول (البلد، iOS)",
"mobile": "الهواتف المحمولة لكل 100",
"platform": "المنصة الأولى (البلد)",
"praying": "المصلّون",
"province": "الولاية / المقاطعة (تقريبية)",
"resources": "الموارد التي وُجدت",
"stateMen": "الولاية متصلة: الرجال",
"stateWomen": "الولاية متصلة: النساء",
"training": "تدريب (بلغتهم)",
"translatedBy": "ترجمة",
"wayIn": "المدخل"
},
"detail": {
"hear": "يشاهدون أو يسمعون ولا يقرؤون",
"none": "لم يُعثر على شيء",
"read": "يقرؤون",
"read_watch": "يقرؤون ويشاهدون",
"stories": "قصص فقط"
},
"drawer": {
"approx": "تقريبي",
"bridgeHas": "بلغة الجسر",
"conn": "الاتصال",
"copied": "تم نسخ الرابط",
"copyLink": "انسخ رابط هذه المجموعة",
"deafLink": "الكتاب المقدس بلغة الإشارة",
"foundNote": "وُجد في المصادر التي فُحصت.",
"messaging": "تطبيقات المراسلة (تنزيلات iOS)",
"mobileNote": "اشتراكات لكل 100 شخص ({year})؛ قد يملك الشخص الواحد عدة اشتراكات.",
"nfhsNote": "NFHS-5، 2019–21، الأعمار 15–49: رقم الولاية، لا رقم المجموعة.",
"noFeed": "هذا البلد غير موجود في موجز Apple: تطبيقات المراسلة فيه غير معروفة هنا، لا غائبة.",
"noneNote": "لم يُعثر على شيء بلغتهم في المصادر التي فُحصت. وهذا ليس ادعاءً بأنه لا يوجد شيء.",
"own": "بلغتهم",
"people": "{n} شخص",
"platformNote": "حصة الزيارات القادمة من منصات التواصل الاجتماعي المرصودة في البلد (StatCounter). تطبيقات المراسلة مثل واتساب غير مقيسة.",
"platforms": "المنصات في البلد",
"praying": "{n} شخص يصلّون على DOXA",
"social": "منصات التواصل (إحالات الويب)",
"stateOnline": "{state}: استخدموا الإنترنت من قبل",
"way": "المدخل",
"wayIn": "المدخل",
"wayNote": "لغة مجاورة وليست توصية: هل تتكلمها المجموعة؟ سؤال يجيب عنه شخص على الأرض.",
"wbSource": "البنك الدولي، {year}: رقم البلد",
"womenMen": "النساء {w} · الرجال {m}"
},
"facet": {
"access": "الوصول بلغتهم",
"all": "عرض الكل ({n})",
"aria": "عوامل التصفية",
"band": "استخدام الإنترنت في البلد",
"bandNote": "رقم البلد (البنك الدولي)، وليس رقم المجموعة أبدًا.",
"country": "البلد",
"fewer": "عرض أقل",
"find": "ابحث…",
"findIn": "ابحث في {what}",
"language": "اللغة",
"messaging": "تطبيق المراسلة الأول (البلد)",
"messagingNote": "الأكثر تنزيلًا في متجر App Store الخاص بالبلد: iOS فقط، ترتيب تنزيلات لا استخدام.",
"province": "الهند والصين: الولاية أو المقاطعة",
"provinceNote": "تقريبي: بحسب مكان وجود الشعب، لا بحسب مدينة.",
"region": "المنطقة",
"religion": "الديانة",
"wayIn": "المدخل حين لا يوجد شيء بلغتهم"
},
"grid": {
"aria": "المجموعات العرقية ضمن النطاق",
"columns": "الأعمدة",
"count": "{n} من {total} مجموعة عرقية",
"empty": "لا توجد مجموعة عرقية تطابق عوامل التصفية هذه.",
"exported": "تم تصدير {n} صف (نُسخت ونُزّلت).",
"selectRow": "اختيار {name}"
},
"headline": {
"a": "{zero} من {total} مجموعة عرقية لم يُعثر لها على شيء بلغتها؛",
"b": "{online} منها تعيش في بلدان يستخدم فيها 60% أو أكثر الإنترنت."
},
"kind": {
"audio": "صوت",
"film": "فيلم",
"scripture": "الكتاب المقدس",
"stories": "قصص",
"video": "فيديو"
},
"legend": {
"band": "استخدام الإنترنت في البلد (البنك الدولي)",
"hide": "إخفاء مفتاح الخريطة",
"modeMen": "الإنترنت: الرجال",
"modeWomen": "الإنترنت: النساء",
"modeZero": "لا شيء بلغتهم",
"nfhs": "الهند فقط: نسبة النساء أو الرجال بين 15 و49 عامًا الذين استخدموا الإنترنت من قبل، حسب الولاية. NFHS-5، 2019–21. لا يوجد مصدر موثّق على مستوى المقاطعات للصين.",
"pins": "بلغتهم",
"provinces": "الهند والصين حسب المقاطعة",
"show": "مفتاح الخريطة"
},
"level": {
"bridge": "لا شيء بلغتهم · جسر فقط",
"own": "شيء ما بلغتهم",
"zero": "لا شيء بلغتهم · لا مدخل بعد"
},
"listChip": "قائمة المجموعات العرقية التي اختارتها هذه الصفحة",
"loadError": "لم يتم تحميل بيانات اللوحة.",
"map": {
"aria": "خريطة المجموعات العرقية، مظلّلة بحسب نسبة استخدام الإنترنت في كل بلد"
},
"notice": {
"pin": "تشير النقطة إلى مكان وجود هذا الشعب في {country}، لا إلى مدينة.",
"pins": "تشير النقطة إلى مكان وجود الشعب في بلده، لا إلى مدينة. {n} مجموعة تشترك في نقطة مع مجموعة أخرى.",
"shares": "تشترك في نقطتها مع {n} مجموعة أخرى.",
"sources": "المصادر:",
"standing": "النقاط تقريبية (مكان وجود الشعب في بلده، لا مدينة). أرقام الإنترنت هي أرقام البلد أو الولاية الهندية، لا أرقام المجموعة أبدًا. «وُجد» تعني وُجد في المصادر التي فُحصت."
},
"panel": {
"charts": "رسوم بيانية",
"filters": "عوامل التصفية",
"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": "اللوحة",
"platforms": "المنصات",
"province": "الهند والصين حسب المقاطعة",
"zero": "لا شيء بلغتهم"
},
"wayIn": {
"bridge": "لغة جسر",
"candidate": "لغة مرشّحة",
"candidateText": "{lang} (مرشّحة، {pct}% من البلد)",
"deaf": "جسر بلغة الإشارة",
"deafText": "{lang} (لغة الإشارة)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Unter 25 % online",
"nodata": "Keine Daten",
"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",
"bandCaveat": "Die Internetnutzung ist die Zahl des Landes (Weltbank), nie die der Gruppe.",
"groups": "Gruppen",
"groupsN": "{n} Gruppen",
"measure": "Zählen",
"messagingTop": "Top-Messenger nach Land im Auswahlbereich",
"notMeasured": "Nicht gemessen",
"people": "Menschen",
"peopleN": "{n} Menschen",
"socialTop": "Top-Social-Plattform nach Land im Auswahlbereich",
"unitCountries": "Länder",
"unitGroups": "Volksgruppen",
"unitPeople": "Menschen",
"zeroByBand": "Nichts in ihrer eigenen Sprache, nach Internetnutzung im Land",
"zeroCaveat": "Jede dieser Gruppen hat einen Zugang: eine Brückensprache, eine Kandidatensprache oder eine Gebärdensprach-Brücke."
},
"col": {
"access": "In eigener Sprache",
"band": "Internet-Stufe",
"bridgeTraining": "Schulung (Brücke)",
"country": "Land",
"deafPublisher": "Gebärdensprach-Verlag",
"internet": "Land online",
"internetYear": "Jahr",
"language": "Sprache",
"messaging": "Top-Messenger (Land, iOS)",
"mobile": "Mobilfunk pro 100",
"platform": "Top-Plattform (Land)",
"praying": "Betende Menschen",
"province": "Bundesstaat / Provinz (ungefähr)",
"resources": "Gefundene Ressourcen",
"stateMen": "Bundesstaat online: Männer",
"stateWomen": "Bundesstaat online: Frauen",
"training": "Schulung (eigene Sprache)",
"translatedBy": "Übersetzt von",
"wayIn": "Zugang"
},
"detail": {
"hear": "Sehen oder hören, nicht lesen",
"none": "Nichts gefunden",
"read": "Lesen",
"read_watch": "Lesen und sehen",
"stories": "Nur Geschichten"
},
"drawer": {
"approx": "ungefähr",
"bridgeHas": "In der Brückensprache",
"conn": "Konnektivität",
"copied": "Link kopiert",
"copyLink": "Link zu dieser Gruppe kopieren",
"deafLink": "Bibel in Gebärdensprache",
"foundNote": "In den geprüften Quellen gefunden.",
"messaging": "Messenger (iOS-Downloads)",
"mobileNote": "Verträge pro 100 Menschen ({year}); eine Person kann mehrere haben.",
"nfhsNote": "NFHS-5, 2019–21, 15–49 Jahre: die Zahl des Bundesstaats, nicht der Gruppe.",
"noFeed": "Dieses Land ist nicht in Apples Feed: Seine Messenger sind hier unbekannt, nicht abwesend.",
"noneNote": "In den geprüften Quellen wurde nichts in ihrer eigenen Sprache gefunden. Das ist keine Behauptung, dass nichts existiert.",
"own": "In ihrer eigenen Sprache",
"people": "{n} Menschen",
"platformNote": "Anteil der Web-Verweise unter den erfassten sozialen Plattformen im Land (StatCounter). Messenger wie WhatsApp werden nicht gemessen.",
"platforms": "Plattformen im Land",
"praying": "{n} Menschen beten auf DOXA",
"social": "Soziale Plattformen (Web-Verweise)",
"stateOnline": "{state}: jemals das Internet genutzt",
"way": "Zugang",
"wayIn": "Zugang",
"wayNote": "Eine benachbarte Sprache, keine Empfehlung: Ob eine Gruppe sie spricht, kann nur jemand vor Ort beantworten.",
"wbSource": "Weltbank, {year}: die Zahl des Landes",
"womenMen": "Frauen {w} · Männer {m}"
},
"facet": {
"access": "Zugang in ihrer eigenen Sprache",
"all": "Alle {n} anzeigen",
"aria": "Filter",
"band": "Internetnutzung im Land",
"bandNote": "Die Zahl des Landes (Weltbank), nie die der Gruppe.",
"country": "Land",
"fewer": "Weniger anzeigen",
"find": "Suchen…",
"findIn": "In {what} suchen",
"language": "Sprache",
"messaging": "Top-Messenger (Land)",
"messagingNote": "Am meisten heruntergeladen im Apple App Store des Landes: nur iOS, ein Download-Rang, keine Nutzung.",
"province": "Indien und China: Bundesstaat oder Provinz",
"provinceNote": "Ungefähr: danach zugeordnet, wo ein Volk lebt, nicht nach einem Ort.",
"region": "Region",
"religion": "Religion",
"wayIn": "Zugang, wenn nichts in ihrer Sprache vorliegt"
},
"grid": {
"aria": "Volksgruppen im Auswahlbereich",
"columns": "Spalten",
"count": "{n} von {total} Volksgruppen",
"empty": "Keine Volksgruppe entspricht diesen Filtern.",
"exported": "{n} Zeilen exportiert (kopiert und heruntergeladen).",
"selectRow": "{name} auswählen"
},
"headline": {
"a": "{zero} von {total} Volksgruppen haben nichts in ihrer eigenen Sprache;",
"b": "{online} davon leben in Ländern, in denen 60 % oder mehr das Internet nutzen."
},
"kind": {
"audio": "Audio",
"film": "Film",
"scripture": "Bibel",
"stories": "Geschichten",
"video": "Video"
},
"legend": {
"band": "Internetnutzung im Land (Weltbank)",
"hide": "Legende ausblenden",
"modeMen": "Internet: Männer",
"modeWomen": "Internet: Frauen",
"modeZero": "Nichts in eigener Sprache",
"nfhs": "Nur Indien: Anteil der Frauen bzw. Männer von 15–49 Jahren, die jemals das Internet genutzt haben, nach Bundesstaat. NFHS-5, 2019–21. Für China gibt es keine geprüfte Quelle auf Provinzebene.",
"pins": "In ihrer eigenen Sprache",
"provinces": "Indien und China nach Provinz",
"show": "Legende"
},
"level": {
"bridge": "Nichts in eigener Sprache · nur Brücke",
"own": "Etwas in eigener Sprache",
"zero": "Nichts in eigener Sprache · noch kein Zugang"
},
"listChip": "Volksgruppenliste, von dieser Seite gewählt",
"loadError": "Die Daten der Übersicht wurden nicht geladen.",
"map": {
"aria": "Karte der Volksgruppen, schattiert danach, wie viele Menschen im Land das Internet nutzen"
},
"notice": {
"pin": "Der Punkt markiert, wo dieses Volk in {country} lebt, nicht einen Ort.",
"pins": "Ein Punkt markiert, wo ein Volk in seinem Land lebt, nicht einen Ort. {n} Gruppen teilen sich einen Punkt mit einer anderen Gruppe.",
"shares": "Er teilt seinen Punkt mit {n} anderen Gruppe(n).",
"sources": "Quellen:",
"standing": "Punkte sind ungefähr (wo ein Volk in seinem Land lebt, nicht ein Ort). Internetzahlen gelten für das Land oder den indischen Bundesstaat, nie für die Gruppe. „Gefunden“ heißt: in den geprüften Quellen gefunden."
},
"panel": {
"charts": "Diagramme",
"filters": "Filter",
"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",
"platforms": "Plattformen",
"province": "Indien & China nach Provinz",
"zero": "Nichts in eigener Sprache"
},
"wayIn": {
"bridge": "Brückensprache",
"candidate": "Kandidatensprache",
"candidateText": "{lang} (Kandidat, {pct} % des Landes)",
"deaf": "Gebärdensprach-Brücke",
"deafText": "{lang} (Gebärdensprache)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Under 25% online",
"nodata": "No data",
"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",
"bandCaveat": "Internet use is the country's figure (World Bank), never the group's.",
"groups": "Groups",
"groupsN": "{n} groups",
"measure": "Count",
"messagingTop": "Top messaging app, by country in scope",
"notMeasured": "Not measured",
"people": "People",
"peopleN": "{n} people",
"socialTop": "Top social platform, by country in scope",
"unitCountries": "countries",
"unitGroups": "people groups",
"unitPeople": "people",
"zeroByBand": "Nothing in their own language, by country internet use",
"zeroCaveat": "Every one of these has a way in: a bridge language, a candidate or a Deaf bridge."
},
"col": {
"access": "In own language",
"band": "Internet band",
"bridgeTraining": "Training (bridge)",
"country": "Country",
"deafPublisher": "Sign-language publisher",
"internet": "Country online",
"internetYear": "Year",
"language": "Language",
"messaging": "Top messaging app (country, iOS)",
"mobile": "Mobile per 100",
"platform": "Top platform (country)",
"praying": "People praying",
"province": "State / province (approx.)",
"resources": "Resources found",
"stateMen": "State online: men",
"stateWomen": "State online: women",
"training": "Training (own language)",
"translatedBy": "Translated by",
"wayIn": "Way in"
},
"detail": {
"hear": "Watch or hear, cannot read",
"none": "Nothing found",
"read": "Read",
"read_watch": "Read and watch",
"stories": "Stories only"
},
"drawer": {
"approx": "approximate",
"bridgeHas": "In the bridge language",
"conn": "Connectivity",
"copied": "Link copied",
"copyLink": "Copy link to this group",
"deafLink": "Signed Scripture",
"foundNote": "Found in the sources checked.",
"messaging": "Messaging apps (iOS downloads)",
"mobileNote": "Subscriptions per 100 people ({year}); one person may hold several.",
"nfhsNote": "NFHS-5, 2019-21, ages 15-49: the state's figure, not the group's.",
"noFeed": "This country is not in Apple's feed: its messaging apps are unknown here, not absent.",
"noneNote": "Nothing found in their own language in the sources checked. That is not a claim that nothing exists.",
"own": "In their own language",
"people": "{n} people",
"platformNote": "Share of web referrals among tracked social platforms in the country (StatCounter). Messaging apps such as WhatsApp are not measured.",
"platforms": "Platforms in the country",
"praying": "{n} people praying on DOXA",
"social": "Social platforms (web referrals)",
"stateOnline": "{state}: ever used the internet",
"way": "Way in",
"wayIn": "Way in",
"wayNote": "A neighbouring language, not a recommendation: whether a group speaks it is a question for someone on the ground.",
"wbSource": "World Bank, {year}: the country's figure",
"womenMen": "women {w} · men {m}"
},
"facet": {
"access": "Access in their own language",
"all": "Show all {n}",
"aria": "Filters",
"band": "Country internet use",
"bandNote": "The country's figure (World Bank), never the group's.",
"country": "Country",
"fewer": "Show fewer",
"find": "Find…",
"findIn": "Find in {what}",
"language": "Language",
"messaging": "Top messaging app (country)",
"messagingNote": "Most downloaded in the country's Apple App Store: iOS only, a download rank, not use.",
"province": "India and China: state or province",
"provinceNote": "Approximate: placed from where a people is found, not a town.",
"region": "Region",
"religion": "Religion",
"wayIn": "Way in when nothing is in their language"
},
"grid": {
"aria": "People groups in scope",
"columns": "Columns",
"count": "{n} of {total} people groups",
"empty": "No people groups match these filters.",
"exported": "{n} rows exported (copied and downloaded).",
"selectRow": "Select {name}"
},
"headline": {
"a": "{zero} of {total} people groups have nothing found in their own language;",
"b": "{online} of them live in countries where 60% or more use the internet."
},
"kind": {
"audio": "Audio",
"film": "Film",
"scripture": "Scripture",
"stories": "Stories",
"video": "Video"
},
"legend": {
"band": "Country internet use (World Bank)",
"hide": "Hide the legend",
"modeMen": "Internet: men",
"modeWomen": "Internet: women",
"modeZero": "Nothing in own language",
"nfhs": "India only: share of women or men aged 15-49 who have ever used the internet, by state. NFHS-5, 2019-21. China has no verified provincial source.",
"pins": "In their own language",
"provinces": "India and China by province",
"show": "Legend"
},
"level": {
"bridge": "Nothing in own language · bridge only",
"own": "Something in own language",
"zero": "Nothing in own language · no way in yet"
},
"listChip": "People-group list, chosen by this page",
"loadError": "The board's data did not load.",
"map": {
"aria": "Map of people groups, shaded by how much of each country uses the internet"
},
"notice": {
"pin": "The dot marks where this people is found in {country}, not a town.",
"pins": "A dot marks where a people is found in its country, not a town. {n} groups share a point with another group.",
"shares": "It shares its point with {n} other group(s).",
"sources": "Sources:",
"standing": "Dots are approximate (where a people is found in its country, not a town). Internet figures are the country's, or the Indian state's, never the group's. \"Found\" means found in the sources checked."
},
"panel": {
"charts": "Charts",
"filters": "Filters",
"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",
"platforms": "Platforms",
"province": "India & China by province",
"zero": "Nothing in own language"
},
"wayIn": {
"bridge": "Bridge language",
"candidate": "Candidate language",
"candidateText": "{lang} (candidate, {pct}% of country)",
"deaf": "Deaf bridge",
"deafText": "{lang} (sign language)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Menos del 25 % en línea",
"nodata": "Sin datos",
"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",
"bandCaveat": "El uso de internet es la cifra del país (Banco Mundial), nunca la del grupo.",
"groups": "Grupos",
"groupsN": "{n} grupos",
"measure": "Contar",
"messagingTop": "App de mensajería principal, por país en la selección",
"notMeasured": "No medido",
"people": "Personas",
"peopleN": "{n} personas",
"socialTop": "Red social principal, por país en la selección",
"unitCountries": "países",
"unitGroups": "grupos étnicos",
"unitPeople": "personas",
"zeroByBand": "Nada en su propio idioma, según el uso de internet del país",
"zeroCaveat": "Cada uno tiene una vía de entrada: un idioma puente, un idioma candidato o un puente en lengua de señas."
},
"col": {
"access": "En su idioma",
"band": "Nivel de internet",
"bridgeTraining": "Capacitación (puente)",
"country": "País",
"deafPublisher": "Editorial en lengua de señas",
"internet": "País en línea",
"internetYear": "Año",
"language": "Idioma",
"messaging": "App de mensajería principal (país, iOS)",
"mobile": "Móviles por 100",
"platform": "Plataforma principal (país)",
"praying": "Personas orando",
"province": "Estado / provincia (aprox.)",
"resources": "Recursos encontrados",
"stateMen": "Estado en línea: hombres",
"stateWomen": "Estado en línea: mujeres",
"training": "Capacitación (idioma propio)",
"translatedBy": "Traducido por",
"wayIn": "Vía de entrada"
},
"detail": {
"hear": "Ver u oír, no leer",
"none": "Nada encontrado",
"read": "Leer",
"read_watch": "Leer y ver",
"stories": "Solo historias"
},
"drawer": {
"approx": "aproximado",
"bridgeHas": "En el idioma puente",
"conn": "Conectividad",
"copied": "Enlace copiado",
"copyLink": "Copiar enlace a este grupo",
"deafLink": "Escrituras en lengua de señas",
"foundNote": "Encontrado en las fuentes consultadas.",
"messaging": "Apps de mensajería (descargas iOS)",
"mobileNote": "Suscripciones por cada 100 personas ({year}); una persona puede tener varias.",
"nfhsNote": "NFHS-5, 2019–21, 15–49 años: la cifra del estado, no la del grupo.",
"noFeed": "Este país no está en el feed de Apple: sus apps de mensajería son desconocidas aquí, no ausentes.",
"noneNote": "No se encontró nada en su propio idioma en las fuentes consultadas. Eso no afirma que no exista nada.",
"own": "En su propio idioma",
"people": "{n} personas",
"platformNote": "Proporción de visitas web desde las redes sociales registradas en el país (StatCounter). No se miden apps de mensajería como WhatsApp.",
"platforms": "Plataformas en el país",
"praying": "{n} personas orando en DOXA",
"social": "Redes sociales (visitas web)",
"stateOnline": "{state}: ha usado internet alguna vez",
"way": "Vía de entrada",
"wayIn": "Vía de entrada",
"wayNote": "Un idioma vecino, no una recomendación: si un grupo lo habla es una pregunta para alguien sobre el terreno.",
"wbSource": "Banco Mundial, {year}: la cifra del país",
"womenMen": "mujeres {w} · hombres {m}"
},
"facet": {
"access": "Acceso en su propio idioma",
"all": "Mostrar los {n}",
"aria": "Filtros",
"band": "Uso de internet del país",
"bandNote": "La cifra del país (Banco Mundial), nunca la del grupo.",
"country": "País",
"fewer": "Mostrar menos",
"find": "Buscar…",
"findIn": "Buscar en {what}",
"language": "Idioma",
"messaging": "App de mensajería principal (país)",
"messagingNote": "La más descargada en la App Store de Apple del país: solo iOS, un ranking de descargas, no de uso.",
"province": "India y China: estado o provincia",
"provinceNote": "Aproximado: ubicado según dónde vive un pueblo, no una ciudad.",
"region": "Región",
"religion": "Religión",
"wayIn": "Vía de entrada cuando no hay nada en su idioma"
},
"grid": {
"aria": "Grupos étnicos en la selección",
"columns": "Columnas",
"count": "{n} de {total} grupos étnicos",
"empty": "Ningún grupo étnico coincide con estos filtros.",
"exported": "{n} filas exportadas (copiadas y descargadas).",
"selectRow": "Seleccionar {name}"
},
"headline": {
"a": "{zero} de {total} grupos étnicos no tienen nada en su propio idioma;",
"b": "{online} de ellos viven en países donde el 60 % o más usa internet."
},
"kind": {
"audio": "Audio",
"film": "Película",
"scripture": "Escrituras",
"stories": "Historias",
"video": "Video"
},
"legend": {
"band": "Uso de internet del país (Banco Mundial)",
"hide": "Ocultar la leyenda",
"modeMen": "Internet: hombres",
"modeWomen": "Internet: mujeres",
"modeZero": "Nada en su idioma",
"nfhs": "Solo India: proporción de mujeres u hombres de 15 a 49 años que alguna vez usaron internet, por estado. NFHS-5, 2019–21. China no tiene una fuente provincial verificada.",
"pins": "En su propio idioma",
"provinces": "India y China por provincia",
"show": "Leyenda"
},
"level": {
"bridge": "Nada en su idioma · solo puente",
"own": "Algo en su idioma",
"zero": "Nada en su idioma · aún sin vía de entrada"
},
"listChip": "Lista de grupos étnicos elegida por esta página",
"loadError": "No se cargaron los datos del tablero.",
"map": {
"aria": "Mapa de grupos étnicos, sombreado según cuánto usa internet cada país"
},
"notice": {
"pin": "El punto marca dónde vive este pueblo en {country}, no una ciudad.",
"pins": "Un punto marca dónde vive un pueblo en su país, no una ciudad. {n} grupos comparten un punto con otro grupo.",
"shares": "Comparte su punto con {n} grupo(s) más.",
"sources": "Fuentes:",
"standing": "Los puntos son aproximados (dónde vive un pueblo en su país, no una ciudad). Las cifras de internet son del país o del estado indio, nunca del grupo. «Encontrado» significa encontrado en las fuentes consultadas."
},
"panel": {
"charts": "Gráficos",
"filters": "Filtros",
"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",
"platforms": "Plataformas",
"province": "India y China por provincia",
"zero": "Nada en su idioma"
},
"wayIn": {
"bridge": "Idioma puente",
"candidate": "Idioma candidato",
"candidateText": "{lang} (candidato, {pct} % del país)",
"deaf": "Puente en lengua de señas",
"deafText": "{lang} (lengua de señas)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Moins de 25 % en ligne",
"nodata": "Pas de données",
"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",
"bandCaveat": "L’usage d’internet est le chiffre du pays (Banque mondiale), jamais celui du groupe.",
"groups": "Groupes",
"groupsN": "{n} groupes",
"measure": "Compter",
"messagingTop": "Messagerie principale, par pays sélectionné",
"notMeasured": "Non mesuré",
"people": "Personnes",
"peopleN": "{n} personnes",
"socialTop": "Réseau social principal, par pays sélectionné",
"unitCountries": "pays",
"unitGroups": "groupes ethniques",
"unitPeople": "personnes",
"zeroByBand": "Rien dans leur propre langue, selon l’usage d’internet du pays",
"zeroCaveat": "Chacun d’eux a une porte d’entrée : une langue passerelle, une langue candidate ou une passerelle en langue des signes."
},
"col": {
"access": "Dans leur langue",
"band": "Niveau internet",
"bridgeTraining": "Formation (passerelle)",
"country": "Pays",
"deafPublisher": "Éditeur en langue des signes",
"internet": "Pays en ligne",
"internetYear": "Année",
"language": "Langue",
"messaging": "Messagerie principale (pays, iOS)",
"mobile": "Mobile pour 100",
"platform": "Plateforme principale (pays)",
"praying": "Personnes qui prient",
"province": "État / province (approx.)",
"resources": "Ressources trouvées",
"stateMen": "État en ligne : hommes",
"stateWomen": "État en ligne : femmes",
"training": "Formation (propre langue)",
"translatedBy": "Traduit par",
"wayIn": "Porte d’entrée"
},
"detail": {
"hear": "Voir ou entendre, sans lire",
"none": "Rien trouvé",
"read": "Lire",
"read_watch": "Lire et voir",
"stories": "Récits seulement"
},
"drawer": {
"approx": "approximatif",
"bridgeHas": "Dans la langue passerelle",
"conn": "Connectivité",
"copied": "Lien copié",
"copyLink": "Copier le lien vers ce groupe",
"deafLink": "Écritures en langue des signes",
"foundNote": "Trouvé dans les sources consultées.",
"messaging": "Messageries (téléchargements iOS)",
"mobileNote": "Abonnements pour 100 personnes ({year}) ; une personne peut en avoir plusieurs.",
"nfhsNote": "NFHS-5, 2019–21, 15–49 ans : le chiffre de l’État, pas celui du groupe.",
"noFeed": "Ce pays n’est pas dans le flux d’Apple : ses messageries sont inconnues ici, pas absentes.",
"noneNote": "Rien n’a été trouvé dans leur propre langue dans les sources consultées. Cela ne veut pas dire que rien n’existe.",
"own": "Dans leur propre langue",
"people": "{n} personnes",
"platformNote": "Part des visites web venant des réseaux sociaux suivis dans le pays (StatCounter). Les messageries comme WhatsApp ne sont pas mesurées.",
"platforms": "Plateformes dans le pays",
"praying": "{n} personnes prient sur DOXA",
"social": "Réseaux sociaux (visites web)",
"stateOnline": "{state} : a déjà utilisé internet",
"way": "Porte d’entrée",
"wayIn": "Porte d’entrée",
"wayNote": "Une langue voisine, pas une recommandation : savoir si un groupe la parle est une question pour quelqu’un sur le terrain.",
"wbSource": "Banque mondiale, {year} : le chiffre du pays",
"womenMen": "femmes {w} · hommes {m}"
},
"facet": {
"access": "Accès dans leur propre langue",
"all": "Tout afficher ({n})",
"aria": "Filtres",
"band": "Usage d’internet du pays",
"bandNote": "Le chiffre du pays (Banque mondiale), jamais celui du groupe.",
"country": "Pays",
"fewer": "Afficher moins",
"find": "Rechercher…",
"findIn": "Rechercher dans {what}",
"language": "Langue",
"messaging": "Messagerie principale (pays)",
"messagingNote": "La plus téléchargée sur l’App Store d’Apple du pays : iOS seulement, un rang de téléchargements, pas l’usage.",
"province": "Inde et Chine : État ou province",
"provinceNote": "Approximatif : placé d’après l’endroit où vit un peuple, pas une ville.",
"region": "Région",
"religion": "Religion",
"wayIn": "Porte d’entrée quand rien n’existe dans leur langue"
},
"grid": {
"aria": "Groupes ethniques sélectionnés",
"columns": "Colonnes",
"count": "{n} sur {total} groupes ethniques",
"empty": "Aucun groupe ethnique ne correspond à ces filtres.",
"exported": "{n} lignes exportées (copiées et téléchargées).",
"selectRow": "Sélectionner {name}"
},
"headline": {
"a": "{zero} des {total} groupes ethniques n’ont rien dans leur propre langue ;",
"b": "{online} d’entre eux vivent dans des pays où 60 % ou plus utilisent internet."
},
"kind": {
"audio": "Audio",
"film": "Film",
"scripture": "Écritures",
"stories": "Récits",
"video": "Vidéo"
},
"legend": {
"band": "Usage d’internet du pays (Banque mondiale)",
"hide": "Masquer la légende",
"modeMen": "Internet : hommes",
"modeWomen": "Internet : femmes",
"modeZero": "Rien dans leur langue",
"nfhs": "Inde uniquement : part des femmes ou des hommes de 15 à 49 ans ayant déjà utilisé internet, par État. NFHS-5, 2019–21. Aucune source provinciale vérifiée pour la Chine.",
"pins": "Dans leur propre langue",
"provinces": "Inde et Chine par province",
"show": "Légende"
},
"level": {
"bridge": "Rien dans leur langue · passerelle seulement",
"own": "Quelque chose dans leur langue",
"zero": "Rien dans leur langue · pas encore de porte d’entrée"
},
"listChip": "Liste de groupes ethniques choisie par cette page",
"loadError": "Les données du tableau de bord n’ont pas été chargées.",
"map": {
"aria": "Carte des groupes ethniques, ombrée selon la part de chaque pays qui utilise internet"
},
"notice": {
"pin": "Le point indique où ce peuple vit en {country}, pas une ville.",
"pins": "Un point indique où un peuple vit dans son pays, pas une ville. {n} groupes partagent un point avec un autre groupe.",
"shares": "Il partage son point avec {n} autre(s) groupe(s).",
"sources": "Sources :",
"standing": "Les points sont approximatifs (où un peuple vit dans son pays, pas une ville). Les chiffres d’internet sont ceux du pays ou de l’État indien, jamais du groupe. « Trouvé » signifie trouvé dans les sources consultées."
},
"panel": {
"charts": "Graphiques",
"filters": "Filtres",
"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",
"platforms": "Plateformes",
"province": "Inde et Chine par province",
"zero": "Rien dans leur langue"
},
"wayIn": {
"bridge": "Langue passerelle",
"candidate": "Langue candidate",
"candidateText": "{lang} (candidate, {pct} % du pays)",
"deaf": "Passerelle en langue des signes",
"deafText": "{lang} (langue des signes)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "25% से कम ऑनलाइन",
"nodata": "कोई आंकड़ा नहीं",
"partial": "25–59% ऑनलाइन",
"yes": "60% या अधिक ऑनलाइन"
},
"centre": {
"aria": "दिखाएँ",
"map": "मानचित्र",
"tree": "भाषा वृक्ष"
},
"charts": {
"accessByBand": "अपनी भाषा में पहुँच, देश के इंटरनेट उपयोग के अनुसार",
"aria": "चार्ट",
"bandCaveat": "इंटरनेट उपयोग देश का आंकड़ा है (विश्व बैंक), समूह का कभी नहीं।",
"groups": "समूह",
"groupsN": "{n} समूह",
"measure": "गिनती",
"messagingTop": "प्रमुख मैसेजिंग ऐप, दायरे के देशों के अनुसार",
"notMeasured": "मापा नहीं गया",
"people": "लोग",
"peopleN": "{n} लोग",
"socialTop": "प्रमुख सोशल प्लेटफ़ॉर्म, दायरे के देशों के अनुसार",
"unitCountries": "देश",
"unitGroups": "जन-समूह",
"unitPeople": "लोग",
"zeroByBand": "अपनी भाषा में कुछ नहीं, देश के इंटरनेट उपयोग के अनुसार",
"zeroCaveat": "इनमें से हर एक के लिए एक रास्ता है: एक सेतु भाषा, एक संभावित भाषा या सांकेतिक भाषा का सेतु।"
},
"col": {
"access": "अपनी भाषा में",
"band": "इंटरनेट श्रेणी",
"bridgeTraining": "प्रशिक्षण (सेतु भाषा)",
"country": "देश",
"deafPublisher": "सांकेतिक भाषा प्रकाशक",
"internet": "देश ऑनलाइन",
"internetYear": "वर्ष",
"language": "भाषा",
"messaging": "प्रमुख मैसेजिंग ऐप (देश, iOS)",
"mobile": "प्रति 100 मोबाइल",
"platform": "प्रमुख प्लेटफ़ॉर्म (देश)",
"praying": "प्रार्थना करने वाले",
"province": "राज्य / प्रांत (अनुमानित)",
"resources": "मिले संसाधन",
"stateMen": "राज्य ऑनलाइन: पुरुष",
"stateWomen": "राज्य ऑनलाइन: महिलाएँ",
"training": "प्रशिक्षण (अपनी भाषा)",
"translatedBy": "अनुवादक",
"wayIn": "रास्ता"
},
"detail": {
"hear": "देख या सुन सकते हैं, पढ़ नहीं सकते",
"none": "कुछ नहीं मिला",
"read": "पढ़ सकते हैं",
"read_watch": "पढ़ और देख सकते हैं",
"stories": "केवल कहानियाँ"
},
"drawer": {
"approx": "अनुमानित",
"bridgeHas": "सेतु भाषा में",
"conn": "कनेक्टिविटी",
"copied": "लिंक कॉपी हो गया",
"copyLink": "इस समूह का लिंक कॉपी करें",
"deafLink": "सांकेतिक भाषा में पवित्र शास्त्र",
"foundNote": "जाँचे गए स्रोतों में मिला।",
"messaging": "मैसेजिंग ऐप (iOS डाउनलोड)",
"mobileNote": "प्रति 100 लोगों पर कनेक्शन ({year}); एक व्यक्ति के कई हो सकते हैं।",
"nfhsNote": "NFHS-5, 2019–21, 15–49 वर्ष: यह राज्य का आंकड़ा है, समूह का नहीं।",
"noFeed": "यह देश Apple की फ़ीड में नहीं है: यहाँ इसके मैसेजिंग ऐप अज्ञात हैं, अनुपस्थित नहीं।",
"noneNote": "जाँचे गए स्रोतों में उनकी अपनी भाषा में कुछ नहीं मिला। इसका मतलब यह नहीं कि कुछ भी मौजूद नहीं है।",
"own": "उनकी अपनी भाषा में",
"people": "{n} लोग",
"platformNote": "देश में ट्रैक किए गए सोशल प्लेटफ़ॉर्म से आने वाले वेब ट्रैफ़िक का हिस्सा (StatCounter)। WhatsApp जैसे मैसेजिंग ऐप मापे नहीं जाते।",
"platforms": "देश में प्लेटफ़ॉर्म",
"praying": "DOXA पर {n} लोग प्रार्थना कर रहे हैं",
"social": "सोशल प्लेटफ़ॉर्म (वेब रेफ़रल)",
"stateOnline": "{state}: कभी इंटरनेट का उपयोग किया",
"way": "रास्ता",
"wayIn": "रास्ता",
"wayNote": "एक पड़ोसी भाषा, सिफ़ारिश नहीं: कोई समूह इसे बोलता है या नहीं, यह ज़मीन पर मौजूद किसी व्यक्ति का प्रश्न है।",
"wbSource": "विश्व बैंक, {year}: देश का आंकड़ा",
"womenMen": "महिलाएँ {w} · पुरुष {m}"
},
"facet": {
"access": "उनकी अपनी भाषा में पहुँच",
"all": "सभी {n} दिखाएँ",
"aria": "फ़िल्टर",
"band": "देश में इंटरनेट उपयोग",
"bandNote": "देश का आंकड़ा (विश्व बैंक), समूह का कभी नहीं।",
"country": "देश",
"fewer": "कम दिखाएँ",
"find": "खोजें…",
"findIn": "{what} में खोजें",
"language": "भाषा",
"messaging": "प्रमुख मैसेजिंग ऐप (देश)",
"messagingNote": "देश के Apple App Store में सबसे अधिक डाउनलोड किया गया: केवल iOS, डाउनलोड रैंक, उपयोग नहीं।",
"province": "भारत और चीन: राज्य या प्रांत",
"provinceNote": "अनुमानित: जहाँ जन-समूह पाया जाता है उसके आधार पर, किसी कस्बे के आधार पर नहीं।",
"region": "क्षेत्र",
"religion": "धर्म",
"wayIn": "जब उनकी भाषा में कुछ न हो, तब का रास्ता"
},
"grid": {
"aria": "दायरे में जन-समूह",
"columns": "कॉलम",
"count": "{total} में से {n} जन-समूह",
"empty": "इन फ़िल्टरों से कोई जन-समूह मेल नहीं खाता।",
"exported": "{n} पंक्तियाँ निर्यात हुईं (कॉपी और डाउनलोड)।",
"selectRow": "{name} चुनें"
},
"headline": {
"a": "{total} में से {zero} जन-समूहों की अपनी भाषा में कुछ नहीं मिला;",
"b": "इनमें से {online} ऐसे देशों में रहते हैं जहाँ 60% या अधिक लोग इंटरनेट का उपयोग करते हैं।"
},
"kind": {
"audio": "ऑडियो",
"film": "फ़िल्म",
"scripture": "पवित्र शास्त्र",
"stories": "कहानियाँ",
"video": "वीडियो"
},
"legend": {
"band": "देश में इंटरनेट उपयोग (विश्व बैंक)",
"hide": "लेजेंड छिपाएँ",
"modeMen": "इंटरनेट: पुरुष",
"modeWomen": "इंटरनेट: महिलाएँ",
"modeZero": "अपनी भाषा में कुछ नहीं",
"nfhs": "केवल भारत: राज्यवार 15–49 वर्ष की महिलाओं या पुरुषों का हिस्सा जिन्होंने कभी इंटरनेट का उपयोग किया है। NFHS-5, 2019–21। चीन के लिए कोई सत्यापित प्रांतीय स्रोत नहीं है।",
"pins": "उनकी अपनी भाषा में",
"provinces": "भारत और चीन, प्रांत के अनुसार",
"show": "लेजेंड"
},
"level": {
"bridge": "अपनी भाषा में कुछ नहीं · केवल सेतु",
"own": "अपनी भाषा में कुछ है",
"zero": "अपनी भाषा में कुछ नहीं · अभी कोई रास्ता नहीं"
},
"listChip": "इस पेज द्वारा चुनी गई जन-समूह सूची",
"loadError": "बोर्ड का डेटा लोड नहीं हुआ।",
"map": {
"aria": "जन-समूहों का मानचित्र, हर देश में इंटरनेट उपयोग के अनुसार रंगा हुआ"
},
"notice": {
"pin": "बिंदु दिखाता है कि यह जन-समूह {country} में कहाँ पाया जाता है, कोई कस्बा नहीं।",
"pins": "बिंदु दिखाता है कि कोई जन-समूह अपने देश में कहाँ पाया जाता है, कोई कस्बा नहीं। {n} समूह किसी दूसरे समूह के साथ एक ही बिंदु साझा करते हैं।",
"shares": "यह अपना बिंदु {n} अन्य समूह(ों) के साथ साझा करता है।",
"sources": "स्रोत:",
"standing": "बिंदु अनुमानित हैं (जहाँ जन-समूह अपने देश में पाया जाता है, कोई कस्बा नहीं)। इंटरनेट के आंकड़े देश या भारतीय राज्य के हैं, समूह के कभी नहीं। \"मिला\" का अर्थ है जाँचे गए स्रोतों में मिला।"
},
"panel": {
"charts": "चार्ट",
"filters": "फ़िल्टर",
"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": "बोर्ड",
"platforms": "प्लेटफ़ॉर्म",
"province": "भारत और चीन, प्रांत के अनुसार",
"zero": "अपनी भाषा में कुछ नहीं"
},
"wayIn": {
"bridge": "सेतु भाषा",
"candidate": "संभावित भाषा",
"candidateText": "{lang} (संभावित, देश का {pct}%)",
"deaf": "सांकेतिक भाषा सेतु",
"deafText": "{lang} (सांकेतिक भाषा)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Meno del 25 % online",
"nodata": "Nessun dato",
"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",
"bandCaveat": "L’uso di internet è il dato del paese (Banca Mondiale), mai quello del gruppo.",
"groups": "Gruppi",
"groupsN": "{n} gruppi",
"measure": "Conta",
"messagingTop": "App di messaggistica principale, per paese selezionato",
"notMeasured": "Non misurato",
"people": "Persone",
"peopleN": "{n} persone",
"socialTop": "Piattaforma social principale, per paese selezionato",
"unitCountries": "paesi",
"unitGroups": "gruppi etnici",
"unitPeople": "persone",
"zeroByBand": "Niente nella propria lingua, per uso di internet del paese",
"zeroCaveat": "Ognuno ha una via d’accesso: una lingua ponte, una lingua candidata o un ponte in lingua dei segni."
},
"col": {
"access": "Nella propria lingua",
"band": "Fascia internet",
"bridgeTraining": "Formazione (ponte)",
"country": "Paese",
"deafPublisher": "Editore in lingua dei segni",
"internet": "Paese online",
"internetYear": "Anno",
"language": "Lingua",
"messaging": "App di messaggistica principale (paese, iOS)",
"mobile": "Cellulari per 100",
"platform": "Piattaforma principale (paese)",
"praying": "Persone che pregano",
"province": "Stato / provincia (approssimativo)",
"resources": "Risorse trovate",
"stateMen": "Stato online: uomini",
"stateWomen": "Stato online: donne",
"training": "Formazione (propria lingua)",
"translatedBy": "Tradotto da",
"wayIn": "Via d’accesso"
},
"detail": {
"hear": "Vedere o ascoltare, non leggere",
"none": "Niente trovato",
"read": "Leggere",
"read_watch": "Leggere e vedere",
"stories": "Solo storie"
},
"drawer": {
"approx": "approssimativo",
"bridgeHas": "Nella lingua ponte",
"conn": "Connettività",
"copied": "Link copiato",
"copyLink": "Copia il link a questo gruppo",
"deafLink": "Scritture in lingua dei segni",
"foundNote": "Trovato nelle fonti consultate.",
"messaging": "App di messaggistica (download iOS)",
"mobileNote": "Abbonamenti ogni 100 persone ({year}); una persona può averne diversi.",
"nfhsNote": "NFHS-5, 2019–21, 15–49 anni: il dato dello stato, non del gruppo.",
"noFeed": "Questo paese non è nel feed di Apple: le sue app di messaggistica qui sono sconosciute, non assenti.",
"noneNote": "Nelle fonti consultate non è stato trovato niente nella loro lingua. Non significa che non esista nulla.",
"own": "Nella propria lingua",
"people": "{n} persone",
"platformNote": "Quota di visite web provenienti dalle piattaforme social monitorate nel paese (StatCounter). Le app di messaggistica come WhatsApp non sono misurate.",
"platforms": "Piattaforme nel paese",
"praying": "{n} persone pregano su DOXA",
"social": "Piattaforme social (visite web)",
"stateOnline": "{state}: ha mai usato internet",
"way": "Via d’accesso",
"wayIn": "Via d’accesso",
"wayNote": "Una lingua vicina, non una raccomandazione: se un gruppo la parla è una domanda per qualcuno sul posto.",
"wbSource": "Banca Mondiale, {year}: il dato del paese",
"womenMen": "donne {w} · uomini {m}"
},
"facet": {
"access": "Accesso nella propria lingua",
"all": "Mostra tutti ({n})",
"aria": "Filtri",
"band": "Uso di internet del paese",
"bandNote": "Il dato del paese (Banca Mondiale), mai quello del gruppo.",
"country": "Paese",
"fewer": "Mostra meno",
"find": "Cerca…",
"findIn": "Cerca in {what}",
"language": "Lingua",
"messaging": "App di messaggistica principale (paese)",
"messagingNote": "La più scaricata nell’App Store di Apple del paese: solo iOS, una classifica di download, non di uso.",
"province": "India e Cina: stato o provincia",
"provinceNote": "Approssimativo: collocato dove vive un popolo, non in una città.",
"region": "Regione",
"religion": "Religione",
"wayIn": "Via d’accesso quando non c’è niente nella loro lingua"
},
"grid": {
"aria": "Gruppi etnici selezionati",
"columns": "Colonne",
"count": "{n} di {total} gruppi etnici",
"empty": "Nessun gruppo etnico corrisponde a questi filtri.",
"exported": "{n} righe esportate (copiate e scaricate).",
"selectRow": "Seleziona {name}"
},
"headline": {
"a": "{zero} dei {total} gruppi etnici non hanno niente nella propria lingua;",
"b": "{online} di loro vivono in paesi dove il 60 % o più usa internet."
},
"kind": {
"audio": "Audio",
"film": "Film",
"scripture": "Scritture",
"stories": "Storie",
"video": "Video"
},
"legend": {
"band": "Uso di internet del paese (Banca Mondiale)",
"hide": "Nascondi la legenda",
"modeMen": "Internet: uomini",
"modeWomen": "Internet: donne",
"modeZero": "Niente nella propria lingua",
"nfhs": "Solo India: quota di donne o uomini di 15–49 anni che hanno mai usato internet, per stato. NFHS-5, 2019–21. Per la Cina non esiste una fonte provinciale verificata.",
"pins": "Nella propria lingua",
"provinces": "India e Cina per provincia",
"show": "Legenda"
},
"level": {
"bridge": "Niente nella propria lingua · solo ponte",
"own": "Qualcosa nella propria lingua",
"zero": "Niente nella propria lingua · ancora nessuna via d’accesso"
},
"listChip": "Elenco di gruppi etnici scelto da questa pagina",
"loadError": "I dati del pannello non sono stati caricati.",
"map": {
"aria": "Mappa dei gruppi etnici, ombreggiata secondo quanto ogni paese usa internet"
},
"notice": {
"pin": "Il punto indica dove vive questo popolo in {country}, non una città.",
"pins": "Un punto indica dove vive un popolo nel suo paese, non una città. {n} gruppi condividono un punto con un altro gruppo.",
"shares": "Condivide il punto con {n} altro/i gruppo/i.",
"sources": "Fonti:",
"standing": "I punti sono approssimativi (dove vive un popolo nel suo paese, non una città). I dati di internet sono del paese o dello stato indiano, mai del gruppo. «Trovato» significa trovato nelle fonti consultate."
},
"panel": {
"charts": "Grafici",
"filters": "Filtri",
"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",
"platforms": "Piattaforme",
"province": "India e Cina per provincia",
"zero": "Niente nella propria lingua"
},
"wayIn": {
"bridge": "Lingua ponte",
"candidate": "Lingua candidata",
"candidateText": "{lang} (candidata, {pct} % del paese)",
"deaf": "Ponte in lingua dei segni",
"deafText": "{lang} (lingua dei segni)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Menos de 25 % online",
"nodata": "Sem dados",
"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",
"bandCaveat": "O uso de internet é o número do país (Banco Mundial), nunca o do grupo.",
"groups": "Grupos",
"groupsN": "{n} grupos",
"measure": "Contar",
"messagingTop": "App de mensagens principal, por país no recorte",
"notMeasured": "Não medido",
"people": "Pessoas",
"peopleN": "{n} pessoas",
"socialTop": "Rede social principal, por país no recorte",
"unitCountries": "países",
"unitGroups": "povos",
"unitPeople": "pessoas",
"zeroByBand": "Nada na própria língua, por uso de internet do país",
"zeroCaveat": "Cada um tem um caminho de entrada: uma língua ponte, uma língua candidata ou uma ponte em língua de sinais."
},
"col": {
"access": "Na própria língua",
"band": "Faixa de internet",
"bridgeTraining": "Treinamento (ponte)",
"country": "País",
"deafPublisher": "Editora em língua de sinais",
"internet": "País online",
"internetYear": "Ano",
"language": "Língua",
"messaging": "App de mensagens principal (país, iOS)",
"mobile": "Celulares por 100",
"platform": "Principal plataforma (país)",
"praying": "Pessoas orando",
"province": "Estado / província (aprox.)",
"resources": "Recursos encontrados",
"stateMen": "Estado online: homens",
"stateWomen": "Estado online: mulheres",
"training": "Treinamento (própria língua)",
"translatedBy": "Traduzido por",
"wayIn": "Caminho de entrada"
},
"detail": {
"hear": "Ver ou ouvir, sem ler",
"none": "Nada encontrado",
"read": "Ler",
"read_watch": "Ler e ver",
"stories": "Só histórias"
},
"drawer": {
"approx": "aproximado",
"bridgeHas": "Na língua ponte",
"conn": "Conectividade",
"copied": "Link copiado",
"copyLink": "Copiar link deste grupo",
"deafLink": "Escrituras em língua de sinais",
"foundNote": "Encontrado nas fontes verificadas.",
"messaging": "Apps de mensagens (downloads iOS)",
"mobileNote": "Assinaturas por 100 pessoas ({year}); uma pessoa pode ter várias.",
"nfhsNote": "NFHS-5, 2019–21, 15–49 anos: o número do estado, não do grupo.",
"noFeed": "Este país não está no feed da Apple: seus apps de mensagens são desconhecidos aqui, não ausentes.",
"noneNote": "Nada foi encontrado na própria língua nas fontes verificadas. Isso não afirma que nada exista.",
"own": "Na própria língua",
"people": "{n} pessoas",
"platformNote": "Parcela de acessos web vindos das redes sociais monitoradas no país (StatCounter). Aplicativos de mensagem como o WhatsApp não são medidos.",
"platforms": "Plataformas no país",
"praying": "{n} pessoas orando no DOXA",
"social": "Redes sociais (acessos web)",
"stateOnline": "{state}: já usou a internet",
"way": "Caminho de entrada",
"wayIn": "Caminho de entrada",
"wayNote": "Uma língua vizinha, não uma recomendação: se um grupo a fala é uma pergunta para alguém no local.",
"wbSource": "Banco Mundial, {year}: o número do país",
"womenMen": "mulheres {w} · homens {m}"
},
"facet": {
"access": "Acesso na própria língua",
"all": "Mostrar todos os {n}",
"aria": "Filtros",
"band": "Uso de internet do país",
"bandNote": "O número do país (Banco Mundial), nunca o do grupo.",
"country": "País",
"fewer": "Mostrar menos",
"find": "Buscar…",
"findIn": "Buscar em {what}",
"language": "Língua",
"messaging": "App de mensagens principal (país)",
"messagingNote": "O mais baixado na App Store da Apple do país: só iOS, um ranking de downloads, não de uso.",
"province": "Índia e China: estado ou província",
"provinceNote": "Aproximado: colocado onde um povo vive, não numa cidade.",
"region": "Região",
"religion": "Religião",
"wayIn": "Caminho de entrada quando não há nada na língua deles"
},
"grid": {
"aria": "Povos no recorte",
"columns": "Colunas",
"count": "{n} de {total} povos",
"empty": "Nenhum povo corresponde a estes filtros.",
"exported": "{n} linhas exportadas (copiadas e baixadas).",
"selectRow": "Selecionar {name}"
},
"headline": {
"a": "{zero} de {total} povos não têm nada na própria língua;",
"b": "{online} deles vivem em países onde 60 % ou mais usam a internet."
},
"kind": {
"audio": "Áudio",
"film": "Filme",
"scripture": "Escrituras",
"stories": "Histórias",
"video": "Vídeo"
},
"legend": {
"band": "Uso de internet do país (Banco Mundial)",
"hide": "Ocultar a legenda",
"modeMen": "Internet: homens",
"modeWomen": "Internet: mulheres",
"modeZero": "Nada na própria língua",
"nfhs": "Só Índia: parcela de mulheres ou homens de 15 a 49 anos que já usaram a internet, por estado. NFHS-5, 2019–21. A China não tem fonte provincial verificada.",
"pins": "Na própria língua",
"provinces": "Índia e China por província",
"show": "Legenda"
},
"level": {
"bridge": "Nada na própria língua · só ponte",
"own": "Algo na própria língua",
"zero": "Nada na própria língua · ainda sem caminho de entrada"
},
"listChip": "Lista de povos escolhida por esta página",
"loadError": "Os dados do painel não carregaram.",
"map": {
"aria": "Mapa dos povos, sombreado pelo quanto cada país usa a internet"
},
"notice": {
"pin": "O ponto marca onde este povo vive em {country}, não uma cidade.",
"pins": "Um ponto marca onde um povo vive no seu país, não uma cidade. {n} grupos dividem um ponto com outro grupo.",
"shares": "Divide o ponto com {n} outro(s) grupo(s).",
"sources": "Fontes:",
"standing": "Os pontos são aproximados (onde um povo vive no seu país, não uma cidade). Os números de internet são do país ou do estado indiano, nunca do grupo. “Encontrado” significa encontrado nas fontes verificadas."
},
"panel": {
"charts": "Gráficos",
"filters": "Filtros",
"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",
"platforms": "Plataformas",
"province": "Índia e China por província",
"zero": "Nada na própria língua"
},
"wayIn": {
"bridge": "Língua ponte",
"candidate": "Língua candidata",
"candidateText": "{lang} (candidata, {pct} % do país)",
"deaf": "Ponte em língua de sinais",
"deafText": "{lang} (língua de sinais)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Sub 25 % online",
"nodata": "Fără date",
"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",
"bandCaveat": "Folosirea internetului este cifra țării (Banca Mondială), niciodată a grupului.",
"groups": "Grupuri",
"groupsN": "{n} grupuri",
"measure": "Numără",
"messagingTop": "Aplicația de mesagerie principală, pe țări selectate",
"notMeasured": "Nemăsurat",
"people": "Oameni",
"peopleN": "{n} oameni",
"socialTop": "Platforma socială principală, pe țări selectate",
"unitCountries": "țări",
"unitGroups": "grupuri etnice",
"unitPeople": "oameni",
"zeroByBand": "Nimic în propria limbă, după folosirea internetului în țară",
"zeroCaveat": "Fiecare are o cale de acces: o limbă punte, o limbă candidată sau o punte în limbajul semnelor."
},
"col": {
"access": "În propria limbă",
"band": "Nivel internet",
"bridgeTraining": "Instruire (punte)",
"country": "Țară",
"deafPublisher": "Editură în limbajul semnelor",
"internet": "Țara online",
"internetYear": "An",
"language": "Limbă",
"messaging": "Aplicația de mesagerie principală (țară, iOS)",
"mobile": "Mobile la 100",
"platform": "Platforma principală (țară)",
"praying": "Oameni care se roagă",
"province": "Stat / provincie (aprox.)",
"resources": "Resurse găsite",
"stateMen": "Stat online: bărbați",
"stateWomen": "Stat online: femei",
"training": "Instruire (propria limbă)",
"translatedBy": "Tradus de",
"wayIn": "Cale de acces"
},
"detail": {
"hear": "Văd sau aud, nu citesc",
"none": "Nimic găsit",
"read": "Citesc",
"read_watch": "Citesc și văd",
"stories": "Doar povestiri"
},
"drawer": {
"approx": "aproximativ",
"bridgeHas": "În limba punte",
"conn": "Conectivitate",
"copied": "Link copiat",
"copyLink": "Copiază linkul către acest grup",
"deafLink": "Scriptura în limbajul semnelor",
"foundNote": "Găsit în sursele verificate.",
"messaging": "Aplicații de mesagerie (descărcări iOS)",
"mobileNote": "Abonamente la 100 de persoane ({year}); o persoană poate avea mai multe.",
"nfhsNote": "NFHS-5, 2019–21, 15–49 ani: cifra statului, nu a grupului.",
"noFeed": "Această țară nu este în fluxul Apple: aplicațiile sale de mesagerie sunt necunoscute aici, nu absente.",
"noneNote": "Nu s-a găsit nimic în propria lor limbă în sursele verificate. Asta nu înseamnă că nu există nimic.",
"own": "În propria lor limbă",
"people": "{n} oameni",
"platformNote": "Ponderea vizitelor web venite de la platformele sociale urmărite în țară (StatCounter). Aplicațiile de mesagerie precum WhatsApp nu sunt măsurate.",
"platforms": "Platforme în țară",
"praying": "{n} oameni se roagă pe DOXA",
"social": "Platforme sociale (vizite web)",
"stateOnline": "{state}: a folosit vreodată internetul",
"way": "Cale de acces",
"wayIn": "Cale de acces",
"wayNote": "O limbă vecină, nu o recomandare: dacă un grup o vorbește este o întrebare pentru cineva de pe teren.",
"wbSource": "Banca Mondială, {year}: cifra țării",
"womenMen": "femei {w} · bărbați {m}"
},
"facet": {
"access": "Acces în propria lor limbă",
"all": "Arată toate ({n})",
"aria": "Filtre",
"band": "Folosirea internetului în țară",
"bandNote": "Cifra țării (Banca Mondială), niciodată a grupului.",
"country": "Țară",
"fewer": "Arată mai puține",
"find": "Caută…",
"findIn": "Caută în {what}",
"language": "Limbă",
"messaging": "Aplicația de mesagerie principală (țară)",
"messagingNote": "Cea mai descărcată în App Store-ul Apple al țării: doar iOS, un clasament de descărcări, nu de utilizare.",
"province": "India și China: stat sau provincie",
"provinceNote": "Aproximativ: plasat după locul unde trăiește un popor, nu un oraș.",
"region": "Regiune",
"religion": "Religie",
"wayIn": "Cale de acces când nu există nimic în limba lor"
},
"grid": {
"aria": "Grupuri etnice selectate",
"columns": "Coloane",
"count": "{n} din {total} grupuri etnice",
"empty": "Niciun grup etnic nu corespunde acestor filtre.",
"exported": "{n} rânduri exportate (copiate și descărcate).",
"selectRow": "Selectează {name}"
},
"headline": {
"a": "{zero} din {total} grupuri etnice nu au nimic în propria lor limbă;",
"b": "{online} dintre ele trăiesc în țări unde 60 % sau mai mult folosesc internetul."
},
"kind": {
"audio": "Audio",
"film": "Film",
"scripture": "Scriptura",
"stories": "Povestiri",
"video": "Video"
},
"legend": {
"band": "Folosirea internetului în țară (Banca Mondială)",
"hide": "Ascunde legenda",
"modeMen": "Internet: bărbați",
"modeWomen": "Internet: femei",
"modeZero": "Nimic în propria limbă",
"nfhs": "Doar India: ponderea femeilor sau a bărbaților de 15–49 ani care au folosit vreodată internetul, pe state. NFHS-5, 2019–21. China nu are o sursă provincială verificată.",
"pins": "În propria lor limbă",
"provinces": "India și China pe provincii",
"show": "Legendă"
},
"level": {
"bridge": "Nimic în propria limbă · doar punte",
"own": "Ceva în propria limbă",
"zero": "Nimic în propria limbă · încă nicio cale de acces"
},
"listChip": "Listă de grupuri etnice aleasă de această pagină",
"loadError": "Datele panoului nu s-au încărcat.",
"map": {
"aria": "Harta grupurilor etnice, colorată după cât folosește internetul fiecare țară"
},
"notice": {
"pin": "Punctul arată unde trăiește acest popor în {country}, nu un oraș.",
"pins": "Un punct arată unde trăiește un popor în țara sa, nu un oraș. {n} grupuri împart un punct cu alt grup.",
"shares": "Își împarte punctul cu alte {n} grup(uri).",
"sources": "Surse:",
"standing": "Punctele sunt aproximative (unde trăiește un popor în țara sa, nu un oraș). Cifrele de internet sunt ale țării sau ale statului indian, niciodată ale grupului. „Găsit” înseamnă găsit în sursele verificate."
},
"panel": {
"charts": "Grafice",
"filters": "Filtre",
"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",
"platforms": "Platforme",
"province": "India și China pe provincii",
"zero": "Nimic în propria limbă"
},
"wayIn": {
"bridge": "Limbă punte",
"candidate": "Limbă candidată",
"candidateText": "{lang} (candidată, {pct} % din țară)",
"deaf": "Punte în limbajul semnelor",
"deafText": "{lang} (limbajul semnelor)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "Менее 25 % в сети",
"nodata": "Нет данных",
"partial": "25–59 % в сети",
"yes": "60 % и более в сети"
},
"centre": {
"aria": "Показать",
"map": "Карта",
"tree": "Дерево языков"
},
"charts": {
"accessByBand": "Доступ на родном языке по уровню интернета в стране",
"aria": "Диаграммы",
"bandCaveat": "Использование интернета — показатель страны (Всемирный банк), а не группы.",
"groups": "Группы",
"groupsN": "{n} групп",
"measure": "Считать",
"messagingTop": "Главный мессенджер по странам в выборке",
"notMeasured": "Не измерено",
"people": "Люди",
"peopleN": "{n} чел.",
"socialTop": "Главная соцсеть по странам в выборке",
"unitCountries": "стран",
"unitGroups": "народов",
"unitPeople": "чел.",
"zeroByBand": "Ничего на родном языке, по уровню интернета в стране",
"zeroCaveat": "У каждого из них есть путь: язык-мост, язык-кандидат или мост на жестовом языке."
},
"col": {
"access": "На родном языке",
"band": "Уровень интернета",
"bridgeTraining": "Обучение (мост)",
"country": "Страна",
"deafPublisher": "Издатель на жестовом языке",
"internet": "Страна в сети",
"internetYear": "Год",
"language": "Язык",
"messaging": "Главный мессенджер (страна, iOS)",
"mobile": "Мобильных на 100",
"platform": "Главная платформа (страна)",
"praying": "Молящихся",
"province": "Штат / провинция (прибл.)",
"resources": "Найденные ресурсы",
"stateMen": "Штат в сети: мужчины",
"stateWomen": "Штат в сети: женщины",
"training": "Обучение (родной язык)",
"translatedBy": "Перевод",
"wayIn": "Путь"
},
"detail": {
"hear": "Смотрят или слушают, не читают",
"none": "Ничего не найдено",
"read": "Читают",
"read_watch": "Читают и смотрят",
"stories": "Только истории"
},
"drawer": {
"approx": "приблизительно",
"bridgeHas": "На языке-мосте",
"conn": "Связь",
"copied": "Ссылка скопирована",
"copyLink": "Скопировать ссылку на эту группу",
"deafLink": "Писание на жестовом языке",
"foundNote": "Найдено в проверенных источниках.",
"messaging": "Мессенджеры (загрузки iOS)",
"mobileNote": "Подписок на 100 человек ({year}); у одного человека может быть несколько.",
"nfhsNote": "NFHS-5, 2019–21, 15–49 лет: показатель штата, а не группы.",
"noFeed": "Этой страны нет в ленте Apple: её мессенджеры здесь неизвестны, а не отсутствуют.",
"noneNote": "В проверенных источниках на их родном языке ничего не найдено. Это не утверждение, что ничего не существует.",
"own": "На их родном языке",
"people": "{n} чел.",
"platformNote": "Доля веб-переходов из отслеживаемых соцсетей в стране (StatCounter). Мессенджеры вроде WhatsApp не измеряются.",
"platforms": "Платформы в стране",
"praying": "{n} чел. молятся на DOXA",
"social": "Соцсети (веб-переходы)",
"stateOnline": "{state}: когда-либо пользовались интернетом",
"way": "Путь",
"wayIn": "Путь",
"wayNote": "Соседний язык, а не рекомендация: говорит ли на нём группа — вопрос к тому, кто на месте.",
"wbSource": "Всемирный банк, {year}: показатель страны",
"womenMen": "женщины {w} · мужчины {m}"
},
"facet": {
"access": "Доступ на их родном языке",
"all": "Показать все ({n})",
"aria": "Фильтры",
"band": "Интернет в стране",
"bandNote": "Показатель страны (Всемирный банк), а не группы.",
"country": "Страна",
"fewer": "Показать меньше",
"find": "Найти…",
"findIn": "Найти в разделе «{what}»",
"language": "Язык",
"messaging": "Главный мессенджер (страна)",
"messagingNote": "Самый скачиваемый в App Store страны: только iOS, рейтинг загрузок, а не использования.",
"province": "Индия и Китай: штат или провинция",
"provinceNote": "Приблизительно: по месту проживания народа, а не по городу.",
"region": "Регион",
"religion": "Религия",
"wayIn": "Путь, когда на их языке ничего нет"
},
"grid": {
"aria": "Народы в выборке",
"columns": "Столбцы",
"count": "{n} из {total} народов",
"empty": "Ни один народ не подходит под эти фильтры.",
"exported": "Экспортировано строк: {n} (скопировано и скачано).",
"selectRow": "Выбрать: {name}"
},
"headline": {
"a": "У {zero} из {total} народов нет ничего на родном языке;",
"b": "{online} из них живут в странах, где интернетом пользуются 60 % и более."
},
"kind": {
"audio": "Аудио",
"film": "Фильм",
"scripture": "Писание",
"stories": "Истории",
"video": "Видео"
},
"legend": {
"band": "Интернет в стране (Всемирный банк)",
"hide": "Скрыть легенду",
"modeMen": "Интернет: мужчины",
"modeWomen": "Интернет: женщины",
"modeZero": "Ничего на родном языке",
"nfhs": "Только Индия: доля женщин или мужчин 15–49 лет, когда-либо пользовавшихся интернетом, по штатам. NFHS-5, 2019–21. Для Китая нет проверенного источника по провинциям.",
"pins": "На их родном языке",
"provinces": "Индия и Китай по провинциям",
"show": "Легенда"
},
"level": {
"bridge": "Ничего на родном языке · только мост",
"own": "Что-то на родном языке",
"zero": "Ничего на родном языке · пути пока нет"
},
"listChip": "Список народов, выбранный этой страницей",
"loadError": "Данные панели не загрузились.",
"map": {
"aria": "Карта народов, закрашенная по доле пользователей интернета в каждой стране"
},
"notice": {
"pin": "Точка показывает, где этот народ живёт в стране ({country}), а не город.",
"pins": "Точка показывает, где народ живёт в своей стране, а не город. Групп, делящих точку с другой группой: {n}.",
"shares": "Делит точку с другими группами: {n}.",
"sources": "Источники:",
"standing": "Точки приблизительны (где народ живёт в своей стране, а не город). Данные об интернете относятся к стране или индийскому штату, а не к группе. «Найдено» значит найдено в проверенных источниках."
},
"panel": {
"charts": "Диаграммы",
"filters": "Фильтры",
"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": "Панель",
"platforms": "Платформы",
"province": "Индия и Китай по провинциям",
"zero": "Ничего на родном языке"
},
"wayIn": {
"bridge": "Язык-мост",
"candidate": "Язык-кандидат",
"candidateText": "{lang} (кандидат, {pct} % страны)",
"deaf": "Мост на жестовом языке",
"deafText": "{lang} (жестовый язык)"
}
}

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

@ -0,0 +1,211 @@
{
"band": {
"no": "上网率低于 25%",
"nodata": "无数据",
"partial": "上网率 25–59%",
"yes": "上网率 60% 或以上"
},
"centre": {
"aria": "显示",
"map": "地图",
"tree": "语言树"
},
"charts": {
"accessByBand": "母语资源情况,按国家互联网使用率",
"aria": "图表",
"bandCaveat": "互联网使用率是国家的数字(世界银行),不是该族群的数字。",
"groups": "族群",
"groupsN": "{n} 个族群",
"measure": "统计",
"messagingTop": "各国(当前范围)最主要的通讯应用",
"notMeasured": "未统计",
"people": "人数",
"peopleN": "{n} 人",
"socialTop": "各国(当前范围)最主要的社交平台",
"unitCountries": "个国家",
"unitGroups": "个族群",
"unitPeople": "人",
"zeroByBand": "母语中一无所有,按国家互联网使用率",
"zeroCaveat": "每一个都有进入途径:桥梁语言、候选语言或手语桥梁。"
},
"col": {
"access": "母语资源",
"band": "互联网等级",
"bridgeTraining": "培训(桥梁语言)",
"country": "国家",
"deafPublisher": "手语出版方",
"internet": "国家上网率",
"internetYear": "年份",
"language": "语言",
"messaging": "最主要的通讯应用(国家,iOS)",
"mobile": "每百人移动用户",
"platform": "主要平台(国家)",
"praying": "代祷人数",
"province": "邦 / 省(大约)",
"resources": "已找到的资源",
"stateMen": "邦上网率:男性",
"stateWomen": "邦上网率:女性",
"training": "培训(母语)",
"translatedBy": "译者",
"wayIn": "进入途径"
},
"detail": {
"hear": "能看或能听,不能读",
"none": "未找到",
"read": "能读",
"read_watch": "能读能看",
"stories": "仅有故事"
},
"drawer": {
"approx": "大约",
"bridgeHas": "以桥梁语言",
"conn": "网络连接",
"copied": "链接已复制",
"copyLink": "复制此族群的链接",
"deafLink": "手语圣经",
"foundNote": "在已查阅的来源中找到。",
"messaging": "通讯应用(iOS 下载)",
"mobileNote": "每百人签约数({year});一人可能有多个。",
"nfhsNote": "NFHS-5,2019–21,15–49 岁:这是该邦的数字,不是族群的数字。",
"noFeed": "该国不在苹果的数据源中:这里不知道它的通讯应用,并不表示没有。",
"noneNote": "在已查阅的来源中未找到他们母语的任何资源。这并不表示不存在任何资源。",
"own": "以他们的母语",
"people": "{n} 人",
"platformNote": "该国已追踪社交平台带来的网页流量占比(StatCounter)。WhatsApp 等即时通讯应用未被统计。",
"platforms": "该国的平台",
"praying": "{n} 人在 DOXA 上代祷",
"social": "社交平台(网页引荐)",
"stateOnline": "{state}:曾经使用过互联网",
"way": "进入途径",
"wayIn": "进入途径",
"wayNote": "这是邻近语言,不是建议:一个族群是否会说,需要由当地的人来回答。",
"wbSource": "世界银行,{year}:国家的数字",
"womenMen": "女性 {w} · 男性 {m}"
},
"facet": {
"access": "以他们母语的资源",
"all": "显示全部 {n} 项",
"aria": "筛选",
"band": "国家互联网使用率",
"bandNote": "国家的数字(世界银行),不是族群的数字。",
"country": "国家",
"fewer": "收起",
"find": "查找…",
"findIn": "在“{what}”中查找",
"language": "语言",
"messaging": "最主要的通讯应用(国家)",
"messagingNote": "该国苹果 App Store 中下载最多的应用:仅限 iOS,是下载排名,不是使用情况。",
"province": "印度和中国:邦或省",
"provinceNote": "大约:按族群所在地放置,不是城镇。",
"region": "地区",
"religion": "宗教",
"wayIn": "母语一无所有时的进入途径"
},
"grid": {
"aria": "当前范围内的族群",
"columns": "列",
"count": "{total} 个族群中的 {n} 个",
"empty": "没有符合这些筛选条件的族群。",
"exported": "已导出 {n} 行(已复制并下载)。",
"selectRow": "选择 {name}"
},
"headline": {
"a": "{total} 个族群中有 {zero} 个在母语中一无所有;",
"b": "其中 {online} 个生活在 60% 或以上人口使用互联网的国家。"
},
"kind": {
"audio": "音频",
"film": "影片",
"scripture": "圣经",
"stories": "故事",
"video": "视频"
},
"legend": {
"band": "国家互联网使用率(世界银行)",
"hide": "隐藏图例",
"modeMen": "互联网:男性",
"modeWomen": "互联网:女性",
"modeZero": "母语中一无所有",
"nfhs": "仅限印度:各邦 15–49 岁女性或男性中曾使用互联网的比例。NFHS-5,2019–21。中国没有经核实的省级来源。",
"pins": "以他们的母语",
"provinces": "印度和中国(按省)",
"show": "图例"
},
"level": {
"bridge": "母语中一无所有 · 仅有桥梁",
"own": "母语中有资源",
"zero": "母语中一无所有 · 尚无进入途径"
},
"listChip": "本页面选定的族群名单",
"loadError": "看板的数据未能加载。",
"map": {
"aria": "族群地图,按各国互联网使用程度着色"
},
"notice": {
"pin": "圆点标示该民族在{country}的所在地区,而不是某个城镇。",
"pins": "圆点标示一个民族在其国家的所在地区,而不是某个城镇。有 {n} 个族群与其他族群共用一个点。",
"shares": "它与另外 {n} 个族群共用这个点。",
"sources": "来源:",
"standing": "圆点是大约位置(民族在其国家的所在地区,不是城镇)。互联网数字是国家或印度某邦的,不是族群的。“找到”是指在已查阅的来源中找到。"
},
"panel": {
"charts": "图表",
"filters": "筛选",
"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": "看板",
"platforms": "平台",
"province": "印度和中国(按省)",
"zero": "母语中一无所有"
},
"wayIn": {
"bridge": "桥梁语言",
"candidate": "候选语言",
"candidateText": "{lang}(候选,占全国 {pct}%)",
"deaf": "手语桥梁",
"deafText": "{lang}(手语)"
}
}

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

@ -57,8 +57,20 @@ const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
// BEFORE loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a
// tree that had everything it needed. It only passed in hand-testing because I had been
// supplying a dummy host — the real invocation path is what exposed it.
if (!API && !process.env.PACKET_RECORDS) {
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build.');
// ⚠ AND THE SAME BUG HAD A SECOND ESCAPE HATCH, fixed 2026-09-27. The note above records this guard
// being taught about PACKET_RECORDS; OFFLINE was never added to it. loadRecords() reads
// `if (process.env.OFFLINE && existsSync(cache)) return ...` — a path that needs no API either — but
// this check ran first and exited 1, so OFFLINE=1 was documented at the top of this file and could
// never once have worked. That is worse than an undocumented feature: it sends a reader down a route
// the program refuses. Any env var that makes the API unnecessary must be named HERE, next to the
// guard, or it is decoration.
const CACHE_OK = process.env.OFFLINE && existsSync(join(__dirname, 'out', '_records.json'));
if (!API && !process.env.PACKET_RECORDS && !CACHE_OK) {
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build,');
console.error(' or OFFLINE=1 to reuse out/_records.json if a previous run cached it.');
if (process.env.OFFLINE) {
console.error(' OFFLINE=1 was set but out/_records.json does not exist — nothing to reuse.');
}
console.error(' Failing closed.');
process.exit(1);
}
@ -149,6 +161,9 @@ function buildFunnel(membership) {
}
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
// The zone enumeration itself, so this generator, the poster generator and upg-100-list.vue cannot
// disagree about which zones exist (see allZones below).
const { buildLegendNodes, leafZones } = await import(join(DATA, 'legend-tree.js'));
async function loadRecords() {
// ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the
@ -168,7 +183,26 @@ async function loadRecords() {
}
const cache = join(__dirname, 'out', '_records.json');
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude';
// ★ THE FIELD LIST IS PART OF THE SELECTOR CONTRACT — every field a selector READS must be asked
// for here, or its zone silently resolves to nothing and gets skipped as "no groups resolved".
//
// ⚠ SIX OF TWENTY-EIGHT ZONES WERE UNREACHABLE FOR THIS ONE REASON, measured 2026-09-27 while
// raising check:packets-all's coverage. This list named country_code and no other selector field,
// while selector-evaluator reads three more:
// rop1 affinity_bloc -> deaf (rop1Code, :51)
// wagf_block wagf_block -> west/east/central-africa (:102)
// wagf_region wagf_region -> europe, and the union (:113, :136)
// so those six zones evaluated to an EMPTY set for every record. The tell was unmistakable once
// looked at: the records cache held 123 groups with "Deaf" in the name and evaluate() still returned
// 0 slugs. It reads as missing DATA and is missing FIELDS.
// ★ DERIVED, NOT TYPED: the presentation fields are listed, the selector fields come from the
// evaluator's own requirements, so adding a selector type that reads a new field is a change in ONE
// place. A hand-maintained superset drifts the moment someone adds a selector — which is exactly
// what happened here.
const PRESENTATION_FIELDS = ['slug', 'name', 'country_code', 'population', 'religion',
'image_url', 'has_photo', 'picture_credit', 'latitude', 'longitude'];
const SELECTOR_FIELDS = ['country_code', 'wagf_block', 'wagf_region', 'rop1'];
const fields = [...new Set([...PRESENTATION_FIELDS, ...SELECTOR_FIELDS])].join(',');
const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`,
{ headers: { accept: 'application/json' } });
if (!r.ok) throw new Error(`PG API ${r.status}`);
@ -177,12 +211,37 @@ async function loadRecords() {
return recs;
}
// ★ ENUMERATE FROM THE LEGEND TREE, NOT FROM clusters.json'S KEYS — the same seam the POSTER
// generator and upg-100-list.vue use, so all three agree on what "a zone" is.
//
// THE DEFECT THIS FIXES, measured 2026-09-27 while making check:packets-all cover every zone:
// this function looped THREE cluster keys — lists (13) + asia_lists (12) + uncovered_lists (2) = 27
// — and clusters.json has a FOURTH that carries zones: deaf_lists (2). legend-tree.js:106 states the
// real arithmetic, "13 + 12 + 2 + 1 deaf = 28", because buildLegendNodes COLLAPSES the two deaf_lists
// into ONE unified leaf (affinity bloc A017, R1.1). So `deaf` is a poster zone and a legend row that
// no packet could ever be generated for: ONLY=deaf exited 1 with "no zone matches".
//
// ⚠ IT PRESENTED AS A COVERAGE GAP AND WAS A REACHABILITY BUG. check:packets-all printed
// "zones covered : 2 of 28", which reads as "26 nobody got round to" — but one of the 26 was
// unreachable by construction, and generating all of them would have silently produced 27, still
// printed against a denominator of 28. The numerator counted packet zones and the denominator counted
// POSTER files (out/posters/*.html), so the two sides were never the same population. A gap you can
// close by working harder and a gap you cannot close at all look identical in that line.
//
// leafZones() carries id/zoneKey/label/count but NOT selector (leaves do not), so the selector is
// re-attached by id exactly as generate.mjs:2801-2808 does it, deaf included.
function allZones() {
const out = [];
const tops = buildLegendNodes(clusters, { records: null });
const byId = {};
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
for (const z of clusters[k] || []) out.push({ ...z, zoneKey: z.id });
for (const z of clusters[k] || []) byId[z.id] = z.selector;
}
return out;
// The unified deaf leaf's selector. DUPLICATED from legend-tree.js:99's DEAF_SELECTOR, which is
// function-scoped rather than exported, and from generate.mjs:2805 which hardcodes the same literal.
// Kept as a literal here rather than exporting a third consumer's dependency mid-hold; if a fourth
// caller ever needs it, export it once from legend-tree.js and delete all three copies.
byId.deaf = { type: 'affinity_bloc', rop1: 'A017' };
return leafZones(tops).map((z) => ({ ...z, zoneKey: z.zoneKey || z.id, selector: byId[z.id] }));
}
// ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────

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

@ -17,7 +17,7 @@ export function pointOf(r) {
const lat = c ? c[1] : (p.latitude ?? p.lat), lng = c ? c[0] : (p.longitude ?? p.lng ?? p.lon)
return Number.isFinite(Number(lat)) && Number.isFinite(Number(lng)) && lat !== '' && lng !== '' ? [Number(lat), Number(lng)] : null
}
const nameOf = (r) => { const p = (r && (r.properties || r)) || {}; return p.name || p.peopleName || p.PeopNameInCountry || '' }
export const nameOf = (r) => { const p = (r && (r.properties || r)) || {}; return p.name || p.peopleName || p.PeopNameInCountry || '' }
/** Build once per roster: Map<coordinate key, names[]>, plus `byName` (name -> [[lat, lng, key]]). */
export function buildColocationIndex(rows) {

View file

@ -103,10 +103,12 @@
"check:translator-contacts:poison": "node internal/check-translator-contacts.mjs --poison",
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs",
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test",
"check:hover-channel": "node plugin-designs/research-map-plus-grid/scripts/check-hover-channel.mjs",
"check:host-locale-contract": "node internal/check-host-locale-contract.mjs",
"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",
@ -193,7 +195,10 @@
"check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test",
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
"check:settled": "node internal/settled-tree-gate.mjs",
"check:setup-order": "node internal/check-setup-order.mjs",
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
"check:regions-uses-library": "node plugin-designs/regions-dashboard/scripts/check-regions-uses-library.mjs",
"check:religion-label-not-value": "node plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs",
"check:share-policy": "node internal/check-share-policy.mjs",
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs",
"check:shell-scroll": "node internal/verify-shell-scroll.mjs",
@ -230,8 +235,8 @@
"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",
"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",
"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",
"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 check:setup-order",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
"lists": "node library/scripts/people-groups/build-lists.mjs",
@ -332,9 +337,11 @@
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
"check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"check:hover-channel": "SPEC-DASHBOARD-KIT D3: the bus's third coordinated channel points at a record without committing to one. Hover looks like a weaker selection, and the cheap implementation is to put the key in selectionSet 'just while hovering' - every consequence of which is bad and none obvious at the call site: useSelectionCamera watches selectionVersion, so a hover would fly the camera at every mousemove (the resize-strobe class, on a photosensitive Driver); the CSV exporter reads selection > filter > all, so a hover would change what an export contains; the table's selection pill and the shell's live region would both narrate the pointer. Runs the real bus: hover holds its own ref, never enters the selection, never bumps the version, never touches the filters, a repeat emits once (hover fires on every mousemove across 2,107 rows), and the three channels clear independently. Does NOT check which panels light up - D3 says panels opt in, and the first opt-in belongs with a consumer that has a reason. No browser.",
"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.",
@ -388,8 +395,10 @@
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
"check:published": "what readers are SERVED must be what we BUILT. Every other gate measures the local tree, so all of them stay green while a stale publish serves something else — and manufactures bug reports that cannot be reproduced. Compares the sha384 per bundle. SKIPs when the published host is unreachable (a network gate that goes red on flaky wifi gets ignored).",
"check:quoted-scripts": "documentation/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
"check:religion-label-not-value": "the library's two RELIGION_FAMILIES sites (useLegendData, useSearch) show religionFamilyLabel() to the reader, while the value a filter or search matches on stays the canonical English family name: a translated label must never become a wire value.",
"check:scrim-viewport": "a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.",
"check:scrim-viewport:self-test": "reverts each scrim to plain inset:0 and requires EVERY case to FAIL — a gate that cannot fail is worse than no gate, and this one measures a property that looks fine in a screenshot either way.",
"check:setup-order": "a line's POSITION in a setup block is not the MOMENT it runs, and two P0s from the Driver's console came from that gap. ARM A: `watch(..., { immediate: true })` runs during setup, so anything it reaches must be declared ABOVE it - research-map.vue threw `Cannot access '_lastFitKey' before initialization` and blanked the whole map, because the watcher called a HOISTED function 350 lines down whose own constants were still in their temporal dead zone. A hoisted function promises that its NAME resolves early, not that its BODY can run early, and that asymmetry is invisible from both ends. ARM B: a lifecycle registrar closes over Vue's current instance, which exists only while setup is on the stack, so `onMounted(async () => { await init(); onBeforeUnmount(clean) })` drops the registration silently - DashboardMapPanel.vue leaked a ResizeObserver on document.body plus a window listener, warning only, feature working, and research-map.vue had a second copy this gate found. The fix is never 'move the line': declare the handle at setup scope and tear it down in a hook registered before the first await. Its own false positives are the interesting part - it went 6 hits -> 1 over three rounds, and every one it dropped was prose or shadowing read as code: `onBeforeUnmount` inside a console.error STRING, `table` inside `new Error(`table not found (${r.status})`)` in four files, and a `const [pins, byCountry]` destructured INSIDE the function, shadowing a computed 220 lines away. So it blanks comments, string text and template text (keeping ${...}, which is code) and subtracts every name a span declares for itself. Self-tests on both shipped defects, both fixes, and all three decoys; exits 3 if any arm goes blind.",
"check:sfc-parse": "every .vue script block must parse as the BUILD parses it — catches the LIVE-CSS backtick that check:template-literal cannot see (it reads CSS comments only). 0 false positives on 83 files, where 3 lookahead heuristics scored 98 and 136.",
"check:shared-reached": "a shared library fix is only as deployed as the last rebuild of EVERY bundle that consumes it. Each bundle ships its own COMPILED copy, so a one-line fix in library/ is live in whichever bundles happened to be rebuilt after it and silently absent from the rest — and every other gate measures ONE leaf, which renders the old behaviour perfectly. Asserts a distinctive marker string per tracked change; consumers are DERIVED from imports, never hardcoded.",
"check:shot-distinctness": "two README slots must not show the SAME view. README shipped \"List — page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT — same url at a different vp (deploy2 mobile) is intended.",

View file

@ -102,26 +102,27 @@ const mapProvider = resolveMapProvider(profileConfig)
const assetsBase = computed(() => resolveAssetsBase(profileConfig?.value || {}))
// ─── Words (library/i18n/locales/<lc>/allPeopleGroups.json, all 11 locales) ────
// Guarded like FilterDropdown: ProfileLoader installs vue-i18n for this profile, but an unguarded
// useI18n() throws on any host without it, and this map must never go blank over a label.
const EN = { title: 'All People Groups', locations: '{n} group–country locations', doxa: 'DOXA people groups',
frontier: 'Frontier — nothing started', unreached: 'Unreached — work beginning', reached: 'Reached — church is there',
hint: 'Click a row to see all of them · click again for everything', loading: 'Loading every people group…',
loadError: 'The map could not be loaded.', pray: 'Pray for them →', population: 'pop {n}' }
// The English for these eleven strings lives in library/i18n/locales/en/allPeopleGroups.json and
// nowhere else. An inline EN map used to duplicate it here as the bare-host fallback; with the
// locale-honouring default that copy had no job left, and a second copy of a catalogue is only a
// place for the two to drift apart.
// C4 wave 3d: the guard is library/composables/useOptionalI18n now — this site needs more than `t`,
// so it takes the {t, te, locale} form rather than useOptionalT.
// ★ EN STAYS THE FALLBACK, passed in. It is an inline English map, not the catalogue, so routing this
// to tStatic would change what a bare host renders. gospel-plugin and exp-shepherd each CHOSE the
// locale-honouring default for their own bundles; all-people-groups has no owner to ask
// (studio agreed to treat it as unowned), so the conservative reading wins: preserve what it renders.
// ★ THE LOCALE-HONOURING FALLBACK, per dx-shepherd's ruling (2026-09-27) and the Driver's goal (d):
// a BARE-HOST EMBED WITH ?lang=ar MUST RENDER ARABIC. Recorded in COMMON.md as the embed contract.
// This file used to pass its inline EN map as the fallback, which served ENGLISH to exactly the hosts
// that cannot install vue-i18n — the seven bundles here that install none. tStatic honours the runtime
// locale and keeps English as the last resort, so those readers now get their own language.
// ★ THE RULING'S CONDITION WAS MEASURED BEFORE THE SWITCH, not after: all ELEVEN keys this file uses
// are present in all ELEVEN locales (0 gaps, counted 2026-09-27), and ar differs from en on 11 of 11,
// so the change is observable rather than cosmetic. With the catalogue complete there is nothing the
// EN map could still rescue: a key it holds is a key tStatic also holds.
// The keys arrive BARE here ('hint', 'pray'), and qualify() adds the 'allPeopleGroups.' prefix, which
// is exactly what the hand-written `const full = 'allPeopleGroups.' + key` did.
// ★ ONE EQUIVALENCE WORTH STATING: the old code asked te() first and only then t(). The helper calls
// t() and falls back when it returns the key unchanged. Same outcome both ways — present key: the
// translation; absent key: EN — and it no longer depends on te() being implemented.
const { t: tr, locale: i18nLocale } = useOptionalI18n('allPeopleGroups', {
fallback: (full) => EN[String(full).replace(/^allPeopleGroups\./, '')]
})
const { t: tr, locale: i18nLocale } = useOptionalI18n('allPeopleGroups')
// locale arrives as a REF when the host installed i18n and a plain string when it did not
// (PosterWall.vue records the same variance), so unwrap both shapes.
const uiLocale = computed(() => {

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

@ -2012,6 +2012,22 @@ watch(() => mapStore.filters, () => {
// would mean guessing a mapping, and a wrong link is worse than an honest gap.
let applyingExternal = false
// ─── FIT THE MAP TO A SET OF PEOPLES: state, declared here on purpose ────────
// These belong with applyFitSlugs() further down, and that is where they used to be. They cannot
// live there: the watcher immediately below is `immediate: true`, so it runs DURING setup and
// calls applyFitSlugs(raw.fitSlugs) — while a `let`/`const` further down the file is still in its
// temporal dead zone. `function applyFitSlugs` hoists; its constants do not, and reading
// _lastFitKey threw `Cannot access '_lastFitKey' before initialization`, which killed the whole
// setup and blanked the map. Anything an immediate watcher can reach must be declared above it.
const FIT_PADDING_PX = 70
const FIT_MAX_ZOOM = 5.5 // several peoples: stop at regional framing, never street level
const SINGLE_ROW_ZOOM = 6 // one people group: its coordinate is a country-scale centroid
const DEGENERATE_SPAN_DEG = 0.01 // a cluster this tight is a point by another route
const WIDE_SPAN_DEG = 110 // past this the set wraps the globe; frame the world instead
const WORLD_ZOOM = 1.45
const FLY_DURATION_MS = 900
let _lastFitKey = ''
watch(() => profileConfig?.value?.externalFilters, (raw) => {
if (!raw || typeof raw !== 'object' || !mapStore) return
applyingExternal = true
@ -2282,13 +2298,20 @@ function emitFilterState() {
// transient fills in and is reported correctly. The retries are bounded so a clear can never be
// swallowed, only delayed.
const isEmpty = !detail.region && !detail.religion && !detail.language && !detail.languageCode
if (isEmpty && _lastEmitWasFilled && _emptyRetries < EMPTY_RETRIES) {
_emptyRetries++
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
return
if (isEmpty && _lastEmitWasFilled) {
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now())
if (!_emptyWaitStartedAt) _emptyWaitStartedAt = now
if (now - _emptyWaitStartedAt < EMPTY_SETTLE_CEILING_MS) {
// Still inside the window in which an empty read is more likely to be the cascade than the
// reader. Re-read rather than believe it; the condition that ends this is the state becoming
// non-empty, not a count running out.
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
return
}
// The ceiling passed and it is still empty: the reader cleared it. Report that.
}
_emptyRetries = 0
_emptyWaitStartedAt = 0
_lastEmitWasFilled = !isEmpty
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
}
@ -2315,8 +2338,19 @@ const EMIT_SETTLE_MS = 60
// genuine clear must always get through, and the worst case here is EMPTY_RETRIES * EMIT_SETTLE_MS
// of delay on a clear — about a fifth of a second, well under what a reader notices, and far below
// the composite's own 1.2s push window so it cannot collide with that.
const EMPTY_RETRIES = 3
let _emptyRetries = 0
// ★ A CEILING ON BELIEVING A CLEAR — NOT A GUESS AT HOW LONG THE CASCADE TAKES.
// rega, row 43 case 3 at 835ec1ac: the retry budget catches the transient on MapLibre and NOT
// reliably on Mapbox (and not at all in ar/mapbox), because 3 x 60ms is still a duration I picked
// without being able to measure the thing it has to outlast. That is the THIRD time I have tuned a
// number on one machine for this one defect, so the number's JOB changes rather than its value.
//
// Before: "wait long enough for the cascade to finish" — unknowable from here, and wrong on any
// machine slower than mine. Now: "keep re-reading until the state is non-empty, and if it is STILL
// empty after this long, the reader really did clear it." Too long merely delays a clear by an
// amount no one perceives; too short reintroduces the bug. So it errs generous, and it stays under
// the composite's 1.2s push window so the two cannot interact.
const EMPTY_SETTLE_CEILING_MS = 900
let _emptyWaitStartedAt = 0
// Whether the LAST report actually carried a selection. Only then is an empty read suspicious: on
// first load, or after a clear that has already been reported, empty is simply the truth.
let _lastEmitWasFilled = false
@ -2338,7 +2372,7 @@ function emitFilterStateSettled() {
// So the decision is taken here, at schedule time, where the flag still means what it says: a
// change that arrived from the host is not a change the reader made, and is not reported back.
if (applyingExternal) return
_emptyRetries = 0 // a fresh user action restarts the budget
_emptyWaitStartedAt = 0 // a fresh user action restarts the window
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
}
@ -2401,15 +2435,9 @@ watch(() => uiStore.religionFilter, (key) => {
else applyDimFilter({ kind: null })
})
// ─── FIT THE MAP TO A SET OF PEOPLES (see the fitSlugs note in the externalFilters watcher) ───
const FIT_PADDING_PX = 70
const FIT_MAX_ZOOM = 5.5 // several peoples: stop at regional framing, never street level
const SINGLE_ROW_ZOOM = 6 // one people group: its coordinate is a country-scale centroid
const DEGENERATE_SPAN_DEG = 0.01 // a cluster this tight is a point by another route
const WIDE_SPAN_DEG = 110 // past this the set wraps the globe; frame the world instead
const WORLD_ZOOM = 1.45
const FLY_DURATION_MS = 900
let _lastFitKey = ''
// ─── FIT THE MAP TO A SET OF PEOPLES ─────────────────────────────────────────
// Its constants and the _lastFitKey guard are declared ABOVE the externalFilters watcher, not here
// beside applyFitSlugs() — see the note there.
function prefersReducedMotion() {
try {
@ -2602,6 +2630,11 @@ let _containerObserver = null
// the scrollport settles (a lazy-mounted host has no final height at any fixed timeout; see the
// comment on _syncScrollZoomToHost above). Torn down in onBeforeUnmount.
let _scrollZoomObserver = null
// Its window-'resize' twin, declared out here for the same reason the observer is: both are created
// after onMounted's `await initializeMap()`, which is past the point where a lifecycle hook can
// still attach. The observer was already torn down below; the listener was not, because its
// onBeforeUnmount sat beside the code that added it and was silently never registered.
let _resyncScrollZoom = null
function applyContainerScale(width) {
const el = rmRoot.value
if (!el) return
@ -2711,7 +2744,7 @@ onMounted(async () => {
})
// See _syncScrollZoomToHost above (BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, mobile pair).
_syncScrollZoomToHost(map.value)
const _resyncScrollZoom = () => _syncScrollZoomToHost(map.value)
_resyncScrollZoom = () => _syncScrollZoomToHost(map.value)
try {
_scrollZoomObserver = new ResizeObserver(_resyncScrollZoom)
const host = (map.value.getContainer && map.value.getContainer()) || null
@ -2721,7 +2754,6 @@ onMounted(async () => {
if (document.body) _scrollZoomObserver.observe(document.body)
} catch (_) { /* no ResizeObserver: the initial call above still answers for a static page */ }
window.addEventListener('resize', _resyncScrollZoom)
onBeforeUnmount(() => window.removeEventListener('resize', _resyncScrollZoom))
})
onBeforeUnmount(() => {
@ -2729,6 +2761,7 @@ onBeforeUnmount(() => {
window.removeEventListener('legend:highlight', onLegendHighlight)
if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null }
if (_scrollZoomObserver) { _scrollZoomObserver.disconnect(); _scrollZoomObserver = null }
if (_resyncScrollZoom) { window.removeEventListener('resize', _resyncScrollZoom); _resyncScrollZoom = null }
mapLayers.stopPrayerGlow()
clustering.cleanup?.()
selectedPin.cleanup?.()

View file

@ -271,11 +271,17 @@ if (matchesLanguage) {
const src = emitterSrc
if (!/_lastEmitWasFilled/.test(src) || !/const isEmpty/.test(src)) {
fails.push('G research-map.vue no longer guards an all-empty report. A dialect click passes through a fully-cleared store state, so the settled read can be taken mid-cascade and ship {language:"",languageCode:""} — which the composite correctly reads as "the reader cleared everything".')
} else if (!/_emptyRetries\s*<\s*EMPTY_RETRIES/.test(src)) {
fails.push('G the empty guard is not BOUNDED — without a retry ceiling a genuine clear is deferred for ever and the grid can never be un-narrowed.')
} else if (!/_emptyRetries = 0/.test(src)) {
fails.push('G the retry budget is never reset, so the guard protects only the first cascade of a session.')
} else notes.push('empty guard: a mid-cascade read is re-taken, a real clear still gets through')
} else if (!/EMPTY_SETTLE_CEILING_MS/.test(src)) {
// ★ THE BOUND'S SHAPE CHANGED AND THE INVARIANT DID NOT. It used to be a retry COUNT; rega
// measured that 3 x 60ms catches the cascade on MapLibre and not on Mapbox, so the count became
// a TIME CEILING — the difference being what the number is FOR. A count is a guess at how long
// the cascade takes, which cannot be known from here; a ceiling is how long before a clear is
// believed, which is a choice. What must never change is that SOME bound exists: without one a
// genuine clear is deferred for ever and the grid can never be un-narrowed.
fails.push('G the empty guard is not BOUNDED — without a ceiling a genuine clear is deferred for ever and the grid can never be un-narrowed.')
} else if (!/_emptyWaitStartedAt = 0/.test(src)) {
fails.push('G the wait window is never reset, so the guard protects only the first cascade of a session.')
} else notes.push('empty guard: re-read until the state fills, with a ceiling before a clear is believed')
}
// ── H. the echo guard survives the coalescing ────────────────────────────────────────────────

View file

@ -1128,7 +1128,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.
@ -1286,7 +1286,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

@ -160,16 +160,23 @@ async function fetchListRecords(list) {
// copy a reader reads FIRST was the untranslated part. The <strong> travels inside the
// translated string so each language can emphasise its own words, which is not something a
// wrapper around an English sentence could do.
// Guarded like every other useI18n() here: it throws on a host without vue-i18n.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
// C4 wave 3c: the guard is library/composables/useOptionalT.js now. humanizeKey STAYS as the fallback,
// passed in, so a bare host still reads a title-cased last segment rather than a dotted key. No
// namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
// ★ ONE BEHAVIOUR GAIN, stated rather than smuggled: the old i18n path was the bare useI18n().t, so on
// a host WITH i18n a missing key rendered the raw dotted key. It now falls through to humanizeKey, the
// same fallback the bare host already got. Nothing that resolves today changes.
const t = useOptionalT('', { fallback: (k) => humanizeKey(k) })
// Guarded like every other useI18n() here: it throws on a host without vue-i18n. useOptionalT stores
// that guard once (host i18n -> catalogue -> the key) instead of a hand-rolled copy.
// No namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
//
// ★ ONE BEHAVIOUR GAIN FROM WAVE 3c (regions-plugin, 4c05e589): the old path was the bare
// useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key. It now falls through.
//
// ★ AND THE humanizeKey FALLBACK IS GONE — the one deliberate departure from 4c05e589, which kept it.
// It rendered a key's last segment title-cased, so a bare host showed "Upg100 Wall Intro" where a
// whole SENTENCE exists: wrong even in English, and untranslatable because it comes from the
// identifier rather than a catalogue. It was kept on the theory that these keys were not fully
// catalogued — I told regions-plugin exactly that, and it was FALSE: this file's three keys are
// present in all ELEVEN locales, 0 gaps (enumerated 2026-09-27; the only three, all string literals).
// ⚠ IT GUARDS A BARE HOST, NOT A MISSING KEY. Those are different failures and I had them confused.
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the
// production maps be localized.
const t = useOptionalT('')
const intro = computed(() => t('messages.upg100WallIntro'))
const heading = computed(() => t('messages.upg100WallHeading'))
const wallTag = computed(() => t('messages.upg100WallTag'))

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

@ -46,17 +46,26 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
// wrapper never did. The chrome a reader sees FIRST was the untranslated part.
//
// ⚠ GUARDED, like every other useI18n() call in this codebase: it THROWS on a host that has not
// installed vue-i18n, and this component is mounted by hosts we do not control. The fallback
// humanises the key so a bare host shows "Select View", never a blank or a raw dotted key.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
// C4 wave 3c: the guard is library/composables/useOptionalT.js now. humanizeKey STAYS as the fallback,
// passed in, so a bare host still reads a title-cased last segment rather than a dotted key. No
// namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
// ★ ONE BEHAVIOUR GAIN, stated rather than smuggled: the old i18n path was the bare useI18n().t, so on
// a host WITH i18n a missing key rendered the raw dotted key. It now falls through to humanizeKey, the
// same fallback the bare host already got. Nothing that resolves today changes.
const t = useOptionalT('', { fallback: (k) => humanizeKey(k) })
// installed vue-i18n, and this component is mounted by hosts we do not control. useOptionalT holds
// that guard once — host i18n first, then tStatic, then the key — instead of a hand-rolled copy here.
// No namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
//
// ★ ONE BEHAVIOUR GAIN FROM WAVE 3c, stated rather than smuggled (regions-plugin, 4c05e589): the old
// i18n path was the bare useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key.
// It now falls through to the catalogue instead.
//
// ★ AND THE humanizeKey FALLBACK IS GONE — the one deliberate departure from 4c05e589, which kept it.
// It rendered a key's last segment title-cased, so a bare host showed "Select View": fine in English
// and simply WRONG for every other reader, because it is derived from the identifier and cannot be
// translated. Worse, it AGREED with en on these four keys (they are named after the words), so it
// looked correct forever while every non-English reader silently got English. tStatic honours the
// reader's locale and still ends at English.
// The four keys this file uses (tabs.map / prayerResources / selectView / views) are present in all
// ELEVEN locales, 0 gaps, enumerated 2026-09-27 — nothing here was relying on the fallback.
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the
// production maps be localized; it changes what seven vue-i18n-less bundles render, which is not a
// decision a migration gets to make for itself.
const t = useOptionalT('')
const cfg = inject('profileConfig', ref(null))
const tk = inject('mapboxToken', ref(''))
@ -184,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
@ -244,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>

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