Update from source f4871401
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-27 04:49:00 +00:00
parent b25d403a90
commit de284359dd
212 changed files with 9316 additions and 1833 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

@ -26,6 +26,12 @@ and a change here can break other maps.
`import … from '@map/components/ViewSelect.vue'`
- **BarChart** — BarChart.vue — THE SHARED HORIZONTAL BAR CHART (hand-rolled SVG, zero dependencies).
`import … from '@map/components/charts/BarChart.vue'`
- **ProgressBar** — ProgressBar.vue — the DX-life progress bar: a single-value track, or a segmented one with a legend.
`import … from '@map/components/charts/ProgressBar.vue'`
- **DashboardFacetRail** — DashboardFacetRail (was Sidebar.vue) — RDASH redesign phase 2: the floating @map SemanticTreeLegend is DROPPED
`import … from '@map/components/dashboards/DashboardFacetRail.vue'`
- **DashboardFilterChips** — DashboardFilterChips — the live filterSet rendered as removable AND-stacked chips
`import … from '@map/components/dashboards/DashboardFilterChips.vue'`
- **PinHint** — PinHint.vue — the conversion coach-mark (bottom-center of any map).
`import … from '@map/components/hints/PinHint.vue'`
- **ApproxPinNote** — ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
@ -84,6 +90,12 @@ 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'`
- **GoalCard** — GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card.
`import … from '@map/components/regions/GoalCard.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'`
- **StatCard** — StatCard.vue — one figure with a caption, on the DX-life card surface.
`import … from '@map/components/regions/StatCard.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).
@ -115,6 +127,8 @@ and a change here can break other maps.
`import … from '@map/composables/retryableAsyncComponent.js'`
- **useAvatarGenerator** — Avatar Generator Composable
`import … from '@map/composables/useAvatarGenerator.js'`
- **useCardSurface** — useCardSurface.js — the DX-life card surface, as ONE rule set shared by every card component.
`import … from '@map/composables/useCardSurface.js'`
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
`import … from '@map/composables/useCountryOutline.js'`
- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
@ -127,6 +141,8 @@ and a change here can break other maps.
`import … from '@map/composables/useDocumentFont.js'`
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
`import … from '@map/composables/useEdgeScrollAffordance.js'`
- **useFacetAggregation** — useFacetAggregation.js — count rows by a facet accessor, and roll the tail into one bucket.
`import … from '@map/composables/useFacetAggregation.js'`
- **useGeocoderSearch** — Shared geocoder→map search logic (kind-switch + Mapbox filter builder + the
`import … from '@map/composables/useGeocoderSearch.js'`
- **useMapAttribution** — useMapAttribution.js — the data-credit line every map MUST carry.
@ -192,6 +208,8 @@ and a change here can break other maps.
`import … from '@map/utils/colocation.js'`
- **countries-meta** — countries-meta.js — a PORT of DX-life's config/countries-meta.ts (326 lines), the rollup
`import … from '@map/utils/countries-meta.js'`
- **countryName** — countryName.js — a country's name in the reader's language.
`import … from '@map/utils/countryName.js'`
- **embedBase** — embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
`import … from '@map/utils/embedBase.js'`
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
@ -233,6 +251,8 @@ and a change here can break other maps.
`import … from '@map/data/country-posters.json'`
- **iso-numeric-to-alpha2** — "4":"AF","8":"AL","12":"DZ","24":"AO","32":"AR","36":"AU","40":"AT","50":"BD",
`import … from '@map/data/iso-numeric-to-alpha2.json'`
- **iso3-to-alpha2** — "_meta": {
`import … from '@map/data/iso3-to-alpha2.json'`
- **langFamilyByIso** — "aar": "Afro-Asiatic",
`import … from '@map/data/langFamilyByIso.json'`
- **langFamilyByLanguage** — {"Chamacoco":"Zamucoan","Ninam":"Yanomaman","Yagua":"Yaguan","Muinane":"Witotoan","Finnish":"Uralic","Hill Mar

View file

@ -127,6 +127,10 @@ const CONSUMERS = [
]
const SOURCES = [
'plugin-designs/research-dashboard/src/composables/useDashboardAggregations.js',
// kit landing 4c-1 split the generic counting half out of the file above. Its palettes STAYED, so
// this gate's subject did not move — but the new module is scanned too, so a private palette
// added to kit code in future is caught on the same rule rather than escaping into the library.
'library/composables/useFacetAggregation.js',
'plugin-designs/research-dashboard/src/composables/useTreeLegendColors.js',
]

View file

@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* check-package-json-unique-keys.mjs — no object in package.json may carry the same key twice.
*
* WHY: JSON.parse keeps the LAST of two equal keys and says nothing, so a duplicate silently kills the
* first entry. "check:religion-label-not-value" sat twice in //scripts, one description dead, while
* check:doc-scripts passed (it reads the parsed object, where only one survives). It was a merge artifact:
* the landing tooling unions both sides' package.json additions. Found by regions-plugin; only Bun's parser
* warned, while running something unrelated.
*
* HOW: a small JSON scanner that tracks every object's keys by position, so it sees what JSON.parse
* discards. The self-test runs on every invocation (a poisoned sample must fail), so the gate cannot go blind.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = process.env.DOXA_REPO || join(dirname(fileURLToPath(import.meta.url)), '..')
/** Every duplicated key, as "<path>: <key>". Assumes text that JSON.parse accepts. */
export function duplicateKeys(text) {
const out = []
const stack = [] // { keys:Set, path, isObj, expectKey }
let i = 0
const readString = () => {
let s = ''; i++
while (text[i] !== '"') { if (text[i] === '\\') { s += text[i] + text[i + 1]; i += 2 } else s += text[i++] }
i++
return s
}
let lastKey = null
while (i < text.length) {
const c = text[i]
const top = stack[stack.length - 1]
if (c === '{' || c === '[') {
const path = top ? `${top.path}${top.isObj ? '.' + lastKey : '[]'}` : '$'
stack.push({ keys: new Set(), path, isObj: c === '{', expectKey: c === '{' })
i++
} else if (c === '}' || c === ']') { stack.pop(); i++ }
else if (c === '"') {
const s = readString()
if (top && top.isObj && top.expectKey) {
if (top.keys.has(s)) out.push(`${top.path}: ${s}`)
top.keys.add(s); lastKey = s; top.expectKey = false
}
} else if (c === ',') { if (top && top.isObj) top.expectKey = true; i++ }
else i++
}
return out
}
// Self-test, every run: a duplicate at depth must be caught, and a clean sample must pass.
const poisoned = '{"scripts":{"a":"x","b":"y"},"//scripts":{"a":"1","c":"2","a":"3"},"v":["a","a"]}'
const selfBad = duplicateKeys(poisoned)
if (selfBad.length !== 1 || !selfBad[0].includes('a')) {
console.error(`PACKAGE-JSON-UNIQUE-KEYS: self-test FAIL — expected exactly one duplicate in the poisoned sample, got ${JSON.stringify(selfBad)}`)
process.exit(3)
}
if (duplicateKeys('{"a":{"b":1},"c":{"b":2},"d":"\\"b\\""}').length) {
console.error('PACKAGE-JSON-UNIQUE-KEYS: self-test FAIL — a clean sample (same key in SIBLING objects, a quoted key in a value) was reported')
process.exit(3)
}
const text = readFileSync(join(ROOT, 'package.json'), 'utf8')
JSON.parse(text)
const dups = duplicateKeys(text)
if (dups.length) {
console.error(`PACKAGE-JSON-UNIQUE-KEYS: FAIL — ${dups.length} duplicated key(s); JSON.parse silently keeps only the last:`)
for (const d of dups) console.error(' ✗ ' + d)
process.exit(1)
}
console.log('PACKAGE-JSON-UNIQUE-KEYS: PASS — every object in package.json has unique keys (self-test: a poisoned duplicate is caught).')

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 agent'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

@ -0,0 +1,203 @@
#!/usr/bin/env node
// check-shadow-css-parses.mjs — `bun run check:shadow-css-parses`. Source-only, headless, no network.
//
// LINE COMMENTS, NOT A BLOCK COMMENT, and that is not a style preference: this file's own header
// quotes the CSS comment delimiters it exists to reason about, so wrapping it in /* */ ended the
// comment early and the gate would not even parse. A file about a reader getting confused by comment
// delimiters is an apt place to be bitten by one.
//
// Does the CSS we assemble as JS strings actually PARSE as CSS?
//
// THE GAP THIS CLOSES WAS MEASURED, not imagined. In REGLIB batch 1 a line-filtered deletion left 9
// orphaned declarations and braces at 12 open / 21 close in regions-list.vue -- and check:sfc-parse
// PASSED it, because that gate only proves the JS parses, and a broken stylesheet inside a valid
// string literal is still a valid string literal. studio confirmed independently that nothing in the
// tree validates CSS syntax: "check:legend-logical-insets reads declarations and would not notice
// unbalanced braces."
//
// ★ IT USES THE REAL PARSERS, AND THE THREE BUGS THAT FORCED THAT ARE THE POINT OF THIS COMMENT.
// The first version hand-rolled a JS lexer to pull the string literals out, and it was wrong three
// times in a row, each time producing a confident FALSE RED against a file that was fine:
// 1. it read /* CSS comments */ inside the CSS as stranded declarations -- the reader must speak
// the language of the text it reads, and this text is CSS, not JS;
// 2. it ran over a whole .vue file, so the apostrophe in GoalCard's prose header ("one caller's
// feature") opened a phantom string literal and it reported `</div>` from the TEMPLATE as CSS;
// 3. PosterWall.vue:518 has `.replace(/"/g, '&quot;')` -- a REGEX LITERAL CONTAINING A QUOTE, which
// no hand-rolled scanner can get right without resolving the regex-vs-division ambiguity.
// Three false reds from one shortcut. So the literals now come out of the Babel AST that the build
// itself parses: comments, template literals, regexes and apostrophes all stop being my problem.
// Only the CSS scan below is hand-written, because CSS is the thing actually being checked.
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as sfcParse } from '@vue/compiler-sfc'
import { parse as babelParse } from '@babel/parser'
// Each copy of this repo grades ITS OWN tree: a hardcoded path once made one copy silently grade
// another worktree's files (check:no-abs-paths, 2026-09-24).
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..')
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
// ── the script block, via the SFC parser the build uses ─────────────────────────────────────────
function scriptsOf(path, src) {
if (!path.endsWith('.vue')) return [src]
const { descriptor } = sfcParse(src, { filename: path })
return [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean)
}
// ── every useShadowStyles(<css>, label) argument, folded to a string ────────────────────────────
// Returns { css, complete } -- complete:false means part of the argument was an identifier (an
// imported constant), so the text is PARTIAL and must not be scanned as if it were the whole sheet.
function shadowCssOf(script, filename) {
const ast = babelParse(script, { sourceType: 'module', plugins: ['jsx'], errorRecovery: true })
const found = []
// Fold a `'a' + 'b' + IDENT` expression into its literal parts, noting whether any part was not one.
function fold(node, acc) {
if (!node) return
if (node.type === 'StringLiteral') { acc.css += node.value; return }
if (node.type === 'TemplateLiteral') {
if (node.expressions.length) acc.complete = false
acc.css += node.quasis.map((q) => q.value.cooked ?? '').join('')
return
}
if (node.type === 'BinaryExpression' && node.operator === '+') { fold(node.left, acc); fold(node.right, acc); return }
acc.complete = false // an identifier, a call, a conditional -- not text we can read here
}
;(function walk(node) {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) { node.forEach(walk); return }
if (node.type === 'CallExpression' && node.callee && node.callee.name === 'useShadowStyles') {
const acc = { css: '', complete: true }
fold(node.arguments[0], acc)
found.push(acc)
}
for (const k of Object.keys(node)) {
if (k === 'loc' || k === 'leadingComments' || k === 'trailingComments') continue
walk(node[k])
}
})(ast.program)
return found
}
// ── scan a CSS string ───────────────────────────────────────────────────────────────────────────
// CSS comments and quoted values are consumed as their own token types: a /* */ is not a declaration
// and a brace inside content:'a{b' does not open a rule.
function scanCss(cssRaw) {
let depth = 0, chunk = '', open = 0, close = 0, i = 0
const orphans = [], emptySelectors = []
const n = cssRaw.length
while (i < n) {
const ch = cssRaw[i]
if (ch === '/' && cssRaw[i + 1] === '*') {
i += 2
while (i < n && !(cssRaw[i] === '*' && cssRaw[i + 1] === '/')) i++
i += 2
continue
}
if (ch === "'" || ch === '"') {
const q = ch
i++
while (i < n && cssRaw[i] !== q) { if (cssRaw[i] === '\\') i++; i++ }
i++
chunk += "''"
continue
}
if (ch === '{') {
open++
if (depth === 0 && !chunk.trim()) emptySelectors.push('(empty selector before "{")')
if (depth === 0 && /;/.test(chunk) && /[A-Za-z-]+\s*:/.test(chunk)) orphans.push(chunk.trim().slice(0, 90))
depth++; chunk = ''; i++
continue
}
if (ch === '}') { close++; depth = Math.max(0, depth - 1); chunk = ''; i++; continue }
chunk += ch
if (depth === 0 && ch === ';' && /[A-Za-z-]+\s*:/.test(chunk)) { orphans.push(chunk.trim().slice(0, 90)); chunk = '' }
i++
}
return { open, close, orphans, emptySelectors }
}
// ── SELF-TESTS: the scanner must be shown to FAIL on the damage it is meant to catch ────────────
console.log('SELF-TESTS (a scanner that cannot fail proves nothing)')
let r = scanCss('.a{ color:red; } @media (x){ .b{ color:blue; } }')
ok(r.open === r.close && !r.orphans.length, `valid CSS scans clean (${r.open}/${r.close} braces, 0 orphans)`)
r = scanCss('.a{ color:red; } padding:4px; margin:0; .b{ color:blue; }')
ok(r.orphans.length === 2, `NEG CONTROL: two stranded declarations are caught (found ${r.orphans.length}) -- the exact batch-1 damage`)
r = scanCss('.a{ color:red; } } }')
ok(r.close - r.open === 2, `NEG CONTROL: unbalanced braces are caught (${r.open} open / ${r.close} close)`)
r = scanCss(".a{ content:'a{b'; }")
ok(r.open === 1 && r.close === 1, `NEG CONTROL: a brace inside a quoted CSS value does not unbalance the count (${r.open}/${r.close})`)
r = scanCss('/* padding:4px; a comment that looks like a declaration */ .a{ color:red; }')
ok(!r.orphans.length, 'NEG CONTROL: a CSS comment containing "padding:4px;" is not read as a stranded declaration')
r = scanCss('.a{ background:url(data:image/svg+xml;base64,AA==); }')
ok(r.open === 1 && !r.orphans.length, 'a data: URL with a ; inside url() does not read as an orphan')
// the three extractor bugs, as regression tests against the files that exposed them
{
const apostrophe = "<!-- one caller's feature -->\n<script setup>\nuseShadowStyles('.a{ color:red; }', 'x')\n</script>\n<template><div class=\"b\"/></template>"
const got = scriptsOf('x.vue', apostrophe).flatMap((s) => shadowCssOf(s, 'x.vue')).map((a) => a.css).join('')
ok(got === '.a{ color:red; }', 'NEG CONTROL: an apostrophe in a .vue prose header cannot leak template text into the CSS')
}
{
const regexy = "const esc = (v) => String(v).replace(/\"/g, '&quot;')\nuseShadowStyles('.a{ color:red; }', 'x')"
const got = shadowCssOf(regexy, 'x.js').map((a) => a.css).join('')
ok(got === '.a{ color:red; }', 'NEG CONTROL: a regex literal containing a quote (PosterWall.vue:518) no longer desynchronises the read')
}
{
const partial = "import { A } from './a.js'\nuseShadowStyles(A + '.b{ color:red; }', 'x')"
const got = shadowCssOf(partial, 'x.js')
ok(got.length === 1 && got[0].complete === false,
'an argument built partly from an imported constant is reported PARTIAL, not scanned as a whole sheet')
}
// ── the tree ────────────────────────────────────────────────────────────────────────────────────
const walk = (d) => readdirSync(d).flatMap((f) => {
const p = join(d, f)
if (statSync(p).isDirectory()) return f === 'node_modules' || f.startsWith('.') ? [] : walk(p)
return /\.(vue|js)$/.test(f) ? [p] : []
})
const rel = (p) => p.slice(REPO.length + 1)
const files = [...walk(join(REPO, 'library')), ...walk(join(REPO, 'plugin-designs'))].sort()
console.log('\nSHADOW CSS, file by file')
let measured = 0
const partials = []
for (const f of files) {
const src = readFileSync(f, 'utf8')
if (!src.includes('useShadowStyles(')) continue
let blocks
try { blocks = scriptsOf(f, src).flatMap((s) => shadowCssOf(s, f)) }
catch (e) { console.log(` FAIL - ${rel(f)}: could not be parsed (${e.message.slice(0, 70)})`); fails++; continue }
for (const { css, complete } of blocks) {
if (!/[{}]/.test(css)) { partials.push(f); continue }
// ★ A PARTIAL ARGUMENT IS STILL SCANNED, and skipping it was a false green waiting to happen.
// region-dashboards.vue builds its sheet as DXLIFE_TOKENS + SHADOW_BOX_SIZING_RESET + literals,
// so the first cut of this check classified it PARTIAL and skipped it -- excluding the very file
// whose CSS I had just cut rules out of, which is the whole reason the check exists. The imported
// constants are complete rule sets, so the literal remainder must balance on its own; if one ever
// straddled a rule this would go red and the label below says where to look.
measured++
const { open, close, orphans, emptySelectors } = scanCss(css)
const note = complete ? '' : ' (literal segments only; imported constants scanned in their own module)'
if (open !== close || orphans.length || emptySelectors.length) {
console.log(` FAIL - ${rel(f)}: ${open} open / ${close} close brace(s)${note}`)
for (const o of orphans) console.log(` orphan declaration: ${o}`)
for (const e of emptySelectors) console.log(` ${e}`)
fails++
} else {
console.log(` ok - ${rel(f)}: ${open} rule(s), braces balanced, 0 orphans${note}`)
}
}
}
ok(measured > 0, `${measured} stylesheet(s) were actually scanned (0 would mean this check measured nothing)`)
if (partials.length) {
console.log('\n NO CSS LITERALS AT ALL (the argument is entirely an imported constant):')
for (const f of [...new Set(partials)]) console.log(` ${rel(f)}`)
}
console.log(fails ? `\nSHADOW-CSS: ${fails} FAIL(s)` : `\nSHADOW-CSS: ${measured} stylesheet(s) parse as CSS -- braces balanced, no stranded declarations`)
process.exit(fails ? 1 : 0)

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

@ -0,0 +1,133 @@
<!--
ProgressBar.vue — the DX-life progress bar: a single-value track, or a segmented one with a legend.
Ported from blocks/_progress-bar.scss plus RegionDetail.vue's scoped .prayer-coverage-segments
override. Three instances in the regions dashboard used it: one segmented bar with a three-key
legend, and two plain adopted/engaged bars.
★ ONE NUMBER FEEDS BOTH THE WIDTH AND THE LEGEND, which is the actual reason this is a component
and not two copies of markup. Before extraction the segment widths were computed from raw counts in
the template while the legend printed those counts separately, so a bar and the number beside it
could disagree and nothing would say so. Here a segment is { value, color, label } and the width
IS value/max -- they cannot drift apart, because there is only one figure.
★ SEGMENT COLOUR IS A VALUE, NOT A CLASS, and this is the ONE substitution in the port. Their
markup (and ours, before this) selected colours with .rd-seg--full / --partial / --none, three
prayer-specific class names. A shared component cannot know what "full prayer coverage" is, and
baking those three names in is exactly the monolith this extraction exists to undo. So the caller
passes a CSS colour -- the regions plugin passes the same var(--dx-accent) / var(--dx-partial) /
var(--dx-track) tokens those classes set, so every rendered colour is unchanged; only the mechanism
moved from a stylesheet rule to an inline background. The three --full/--partial/--none rules are
deleted rather than left behind, because a rule no selector can reach is not a fallback.
THE THREE TEMPLATE BRANCHES, and why there are no comments between them: a comment sitting between
v-if branches is a RENDERED NODE, which turns the component's root into a FRAGMENT
(<!--[-->...<!--]-->) instead of a single element. Measured by server-rendering it. So the branches
are bare and the explanation lives here:
• showLegend -> root is the ported .stack.stack--xs wrapper, 10px above the legend
• segments only -> root is the segmented .rd-bar
• neither -> root IS the plain .rd-bar, exactly as their cards 2 and 3 render it
DOM IS UNCHANGED FOR ALL THREE INSTANCES, deliberately, because the clone is measured against the
live site. With a legend the root is the ported .stack.stack--xs wrapper (10px between bar and
legend); without one the root IS the .rd-bar element, exactly as their cards 2 and 3 render it --
hence the three template branches rather than one uniform wrapper, which would have added a box
DX-life does not have to two of the three cards.
NOT ADDED HERE, ON PURPOSE: role="progressbar" / aria-valuenow. Neither their bar nor our port has
it, and inventing an accessibility tree the clone target does not have is a change to request, not
to slip in. Carded for a ruling instead.
CONTRACT: the containing shadow root carries the DX-life token layer (--dx-track, --dx-accent,
--spacing-*). No i18n is installed or required -- every string arrives pre-localized.
-->
<script setup>
import { computed } from 'vue'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const props = defineProps({
/** The filled amount, in the same units as `max`. Ignored when `segments` is non-empty. */
value: { type: Number, default: 0 },
/** The denominator. A zero or missing max renders an empty bar rather than dividing by zero. */
max: { type: Number, default: 100 },
/** The single-value fill colour. Any CSS colour; defaults to the DX-life accent. */
color: { type: String, default: 'var(--dx-accent)' },
/**
* [{ value, color, label }] — when non-empty the bar is segmented. `value` is a RAW count over the
* same `max`, not a percentage: the component divides, so the legend and the width agree.
*/
segments: { type: Array, default: () => [] },
/** Render the legend built from `segments`. Has no effect without segments. */
legend: { type: Boolean, default: false },
/** aria-label for the track. Omitted entirely when empty, as their plain bars have none. */
label: { type: String, default: '' },
/** Formats a segment's value for the legend. The caller owns locale-dependent formatting. */
format: { type: Function, default: (n) => String(n) },
/** Between a legend key's label and its number. A prop because ':' is not universal punctuation. */
separator: { type: String, default: ': ' },
})
// RegionDetail.vue:28-30. Capped at 100 for the same reason theirs is: a bar wider than its
// track is a rendering bug, not a data story. Ported verbatim from the plugin's percentOfSubject,
// which existed only to serve these bars and is deleted there.
const percentOf = (count) => (props.max ? Math.min(100, ((count || 0) / props.max) * 100) : 0)
const isSegmented = computed(() => props.segments.length > 0)
const showLegend = computed(() => props.legend && isSegmented.value)
const pct = computed(() => percentOf(props.value))
const segStyles = computed(() => props.segments.map((s) => ({
width: percentOf(s.value) + '%',
background: s.color,
})))
useShadowStyles(
// ── .progress-bar: blocks/_progress-bar.scss. The HEIGHT comes from padding-top, not height
// -- clamp(15px, 6%, 25px) at [data-size="md"] -- so the track scales with its own width.
'.rd-bar{ position:relative; overflow:hidden; width:100%; border-radius:100px;' +
' background:var(--dx-track); border:none; padding-top:clamp(15px, 6%, 25px); }' +
'.rd-bar__slider{ position:absolute; top:0; height:100%; width:0; border-radius:100px;' +
' background:var(--dx-accent); transition:width 1s ease-out; }' +
// .prayer-coverage-segments overrides that padding with a real height (their own scoped rule)
'.rd-bar--segments{ display:flex; height:clamp(15px, 6cqw, 25px); padding-top:0; }' +
'.rd-seg{ height:100%; transition:width 1s ease-out; }' +
// The ported .stack.stack--xs that separates the bar from its legend by 10px. It is the root only
// when a legend is rendered, so a plain bar keeps their exact single-element shape.
'.rd-bar-stack{ display:flex; flex-direction:column; }' +
'.rd-bar-stack > * + *{ margin-top:10px; }' +
'.rd-legend{ display:grid; justify-content:center; gap:3.5px; font-size:var(--font-size-sm);' +
' font-weight:600; text-align:center; }' +
'.rd-legend span{ display:inline-flex; align-items:center; justify-content:center; gap:4px; }' +
'.rd-key{ display:inline-block; flex:0 0 auto; width:7.5px; height:7.5px; border-radius:100px; }' +
// ★ THE COMPONENT CARRIES ITS OWN REDUCED-MOTION GUARD, and it is not redundant. base/_reset.scss
// does this with a DOCUMENT-level universal selector, which styles nothing inside a shadow root, so
// the regions plugin re-ported it scoped to its own `.rd-root *`. That covers these bars only while
// they are inside .rd-root. A shared component used anywhere else would carry the 1s width
// transition with nothing suppressing it -- reintroducing the exact regression that was measured
// and fixed here (theirs 1s -> 1e-05s under emulated reduce, ours stayed 1s: a vestibular visitor
// got a full one-second sliding bar their own site suppresses). A component that ships an animation
// owes the guard for it; relying on an ancestor's reset is what makes a component non-portable.
'@media (prefers-reduced-motion: reduce){' +
' .rd-bar__slider, .rd-seg{ transition-duration:0.01ms !important; } }'
, 'progress-bar')
</script>
<template>
<div v-if="showLegend" class="rd-bar-stack">
<div class="rd-bar rd-bar--segments" :aria-label="label || undefined">
<div v-for="(s, i) in segStyles" :key="i" class="rd-seg" :style="s" />
</div>
<div class="rd-legend">
<span v-for="(s, i) in segments" :key="i">
<i class="rd-key" :style="{ background: s.color }" />{{ s.label }}{{ separator }}{{ format(s.value) }}
</span>
</div>
</div>
<div v-else-if="isSegmented" class="rd-bar rd-bar--segments" :aria-label="label || undefined">
<div v-for="(s, i) in segStyles" :key="i" class="rd-seg" :style="s" />
</div>
<div v-else class="rd-bar" :aria-label="label || undefined">
<div class="rd-bar__slider" :style="{ width: pct + '%', background: color }" />
</div>
</template>

View file

@ -15,20 +15,41 @@ const L = (key, en) => tStatic('dashboard.' + key) || en
* --rd-accent left rail. Reuses aggregate()/lbl() from @map/composables/useDashboardAggregations and
* the same per-row facet → bus.setFilter wiring.
*/
/**
* ── KIT LANDING 4c-3: MOVED HERE FROM plugin-designs/research-dashboard/ ──────────────────────
* The facet rail follows the chip bar (4c-2). Rendering is unchanged; what changed is where its two
* remaining plugin imports point. 4b had already removed the research COLOUR and LABEL tables (they
* stay in plugin-designs and arrive on dashboardConfig.legend), which left exactly two:
* useDashboardAggregations -> @map/composables/useFacetAggregation.js (extracted by 4c-1)
* useDashboardShell -> @map/composables/useDashboardSearch.js (extracted by 4a)
* Both repoint at the MODULE that owns the function rather than at a re-export: a library component
* reading a plugin's shim is the dependency this move exists to remove, and check:filter-multivalue
* already records why it follows the file — left at the plugin path it read the shim, found no
* predicate to object to, and PASSED VACUOUSLY.
*
* Unlike the chip bar, this component IS mounted and IS on screen (research-dashboard.vue), so the
* move is verifiable by eyes as well as by gate — and it needs them: it is 770 lines of tree, search
* and drag behaviour, and nothing about "the imports resolve" says the rail still opens four trees
* by default or that its search still narrows.
*/
import { inject, ref, reactive, computed, onMounted } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { aggregate, lbl } from '../../composables/useDashboardAggregations.js'
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
import { useTreeLegendColors } from '../../composables/useTreeLegendColors.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
import { aggregate, lbl, otherLabel } from '@map/composables/useFacetAggregation.js'
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
// ★ NO RESEARCH COLOUR IMPORTS (kit landing 4b). This component used to import
// useTreeLegendColors and useTreeLegendLabels, which are the DOXA palettes and the People code
// tables — research-specific, and they stay in plugin-designs. Importing them is what stopped this
// file from being able to move into the kit at all. It now declares what it needs and the shell
// supplies it on dashboardConfig.legend; see the note beside that provide().
const dash = inject('dashboardData', null)
const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
// DataSourceManager-normalized features (same store the geocoder bars use) — the shape
// useAffinityBlockLegendData resolves labels from (affinityBlockLabel / clusterLabel / peopleGroupLabel).
const dataStore = inject('dataStore', null)
const searchSourceId = inject('searchSourceId', null)
// `dataStore` and `searchSourceId` were injected here ONLY to construct useTreeLegendColors and
// useTreeLegendLabels, and landing 4b removed both constructions. They are gone rather than left
// harmlessly in place: an unused inject is a claim about the host, and a kit component that appears
// to need a research dataStore is one a new dashboard will dutifully provide before finding out it
// was never read. The chip bar shed the same pair in 4c-2, for the same reason.
const filterSet = bus?.filterSet || {}
// facet-key -> row accessor (sourced from config) — drives both the count tally and setFilter.
@ -134,7 +155,12 @@ function isExpandedRaw(facet, value) {
function toRows(agg, facet, level) {
const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1
return agg.map((d) => ({
facet, value: d.key, label: d.key, count: d.value, frac: d.value / max, level,
// ★ THE BUCKET'S KEY IS A SENTINEL, ITS LABEL IS A TRANSLATION (kit landing 4c-1).
// `value` stays d.key — including the English 'Other' — because the row's inertness is tested
// against it (`row.other` is set from it below, and the template refuses to make such a row
// clickable). Translating the value would make the bucket filterable in Arabic, on a value no
// row carries. Only the LABEL is projected.
facet, value: d.key, label: d.other ? otherLabel() : d.key, count: d.value, frac: d.value / max, level,
other: !!d.other, active: !d.other && bus.isFilterActive(facet, d.key),
// Carried per-ROW rather than looked up in the template, so the zero-row baseline snapshot
// (which spreads a row object) keeps the mark when a row is re-admitted.
@ -328,13 +354,24 @@ function addTrunks(rows) {
// The maps were built inline here, which made this rail the ONLY surface that projected them —
// the filter chip bar rendered the raw code. They now live in useTreeLegendLabels, next to
// useTreeLegendColors, and every People-facing surface reads that one copy.
const { affinityLabelMaps } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
// ★ THE KIT'S OWN NEUTRAL, NOT the research palette's. useTreeLegendColors exports a NEUTRAL with
// the same value today, and importing it would re-create the dependency this landing removes. The
// two are deliberately NOT coupled: a consumer with no palette gets a plainly uncoloured rail, and
// the research palette remains free to change its own neutral without reaching in here.
const KIT_NEUTRAL_SWATCH = '#c4c8c0'
// A dashboard that supplies no `legend` gets an honest degraded rail — uncoloured swatches and raw
// values — rather than a crash. That is the state go-int sees before it wires its own palette, and
// a half-configured dashboard should look plain, not broken.
const legend = computed(() => (cfg && cfg.legend) || null)
const colorFor = (...a) => (legend.value?.colorFor ? legend.value.colorFor(...a) : KIT_NEUTRAL_SWATCH)
const legendLabelFor = (facet, value) => (legend.value?.labelFor ? legend.value.labelFor(facet, value) : value)
// ── COLOURS: sourced from the cap-3 research map's colour strategies ────────────────────────
// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, via
// useTreeLegendColors — so the legend can never drift from the map. Colour is applied AFTER the
// rows exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched.
const { colorFor } = useTreeLegendColors()
// ── COLOURS: still the research map's strategies, now HANDED IN rather than imported ─────────
// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, so the
// legend cannot drift from the map — but the rail no longer reaches for them itself. The shell
// supplies them as dashboardConfig.legend.colorFor (kit landing 4b), which is what lets this file
// become kit code while the DOXA palettes stay in plugin-designs. Colour is applied AFTER the rows
// exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched.
// A row inherits its ANCESTOR's colour, mirroring the map's semantic tree where one hue owns a
// whole subtree (useAffinityBlockLegendData.js:169 assigns one colour per bloc and reuses it for
@ -379,8 +416,14 @@ const sections = computed(() => (cfg.facetRail || []).map((s) => {
// For a tree-backed section (People), swap the aggregated CODE keys for the composable's human
// names — display only; facet/value/filter identity stays the code so filtering matches the map.
if (s.tree === 'affinity') {
const maps = affinityLabelMaps.value
rows = rows.map((r) => (r.other ? r : { ...r, label: (maps[r.facet] && maps[r.facet].get(r.value)) || r.label }))
// labelFor returns the VALUE unchanged when it has no mapping, so "did it map?" is
// `mapped !== r.value` — and an unmapped row keeps the label it already had, exactly as the
// affinityLabelMaps lookup did with its `|| r.label`.
rows = rows.map((r) => {
if (r.other) return r
const mapped = legendLabelFor(r.facet, r.value)
return mapped !== r.value ? { ...r, label: mapped } : r
})
}
rows = addColors(rows, s.axis, religionFamKeys.value)
// Trunks are computed LAST, over the FINAL row list, so the ├/└ glyphs reflect exactly what

View file

@ -1,8 +1,26 @@
<script setup>
/**
* FilterChips — RDASH phase 5: the live filterSet rendered as removable AND-stacked chips
* (Islam ✕ | South Asia ✕ | …) + Clear all. The chip bar IS the live query state (plan §INTERACTION)
* and is what CSV export reads later. Click delegation on the bar (defineCustomElement-safe).
* DashboardFilterChips — the live filterSet rendered as removable AND-stacked chips
* (Islam ✕ | South Asia ✕ | …) + Clear all. The chip bar IS the live query state and is what CSV
* export reads later. Click delegation on the bar, which is what makes it defineCustomElement-safe.
*
* ── KIT LANDING 4c-2: MOVED HERE FROM plugin-designs/research-dashboard/ ──────────────────────
* Nothing about the rendering changed. What made the move possible was landing 4b, which inverted
* the one dependency that blocked it: this file used to import useTreeLegendLabels — the People
* CODE → NAME tables, which are research-specific and STAY in plugin-designs — and a library
* component importing a plugin composable is exactly what check:no-cross-plugin-imports refuses.
* Now the resolver arrives on dashboardConfig.legend and this file needs no research anything.
*
* ★ AN EXPLICIT EXCEPTION TO library/components/WARNING.md, recorded so the next reader does not
* think the rule was missed. That file says a component graduates into the library only once MORE
* THAN ONE map uses it. This one is used by ZERO: the topbar dedup pass unmounted it when
* SearchFilterHeader became the single source of truth for showing and clearing filters, and it has
* stayed unmounted deliberately. It is here because the kit's promise runs the other way round — a
* NEW dashboard mounts kit components, so waiting for a second consumer before providing the first
* one is a deadlock. The honest consequence: its passing gate means "it mounts and degrades", not
* "it looks right", and nobody has SEEN it since the dedup. Anyone wiring it into a dashboard should
* expect to be its first real reader, and must not mount it alongside a SearchFilterHeader that is
* already showing chips — two chip rows over one filterSet is the bug the dedup removed.
*/
import { inject, computed } from 'vue'
import { tStatic } from '@map/i18n/index.js'
@ -12,14 +30,14 @@ import { tStatic } from '@map/i18n/index.js'
// takes the subtree with it.
const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
// No research label import (kit landing 4b) — the shell supplies it on dashboardConfig.legend.
const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
// Same store the facet rail reads — the source useAffinityBlockLegendData resolves People
// codes to human names from.
const dataStore = inject('dataStore', null)
const searchSourceId = inject('searchSourceId', null)
// `dataStore` and `searchSourceId` were injected here ONLY to construct useTreeLegendLabels, and
// landing 4b removed that construction. They are gone rather than left harmlessly in place: an
// unused inject is a claim about the host, and a kit component that appears to need a research
// dataStore is one a new dashboard will dutifully provide before finding out it was never read.
const filterSet = bus?.filterSet || {}
// ── People codes must not reach this chip ────────────────────────────────────────────────────
@ -29,7 +47,14 @@ const filterSet = bus?.filterSet || {}
// DISPLAY ONLY: `k` (the facet key) and the stored filterSet value are never rewritten — the bus
// matches the accessor's output byte-for-byte, and a chip that merely looked right while the
// table stopped responding would be a worse bug than the one being fixed.
const { labelFor } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
// ★ THE UNCONFIGURED DEFAULT JOINS A LIST, because a filterSet value is an ARRAY once two values
// are selected (spec D1) and 4b's fallback returned it untouched — so `String(value)` printed
// "A012,A013", comma-jammed, while a dashboard that DID supply a resolver got "South Asian Peoples,
// Bengali Peoples". Configured and unconfigured should differ in whether CODES RESOLVE, never in
// punctuation: the second difference reads as a rendering bug rather than as missing configuration.
// Kept as one liftable expression on purpose — check:legend-mirrors pulls it out of this source and
// drives it, so "no resolver yields the raw value" is measured instead of asserted.
const labelFor = (facet, value) => (cfg?.legend?.labelFor ? cfg.legend.labelFor(facet, value) : (Array.isArray(value) ? value.join(', ') : value))
const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({
k,
text: String(labelFor(k, v)),

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

@ -0,0 +1,156 @@
<!--
GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card.
The agent named these directly: "the banners are the cards that tell you the prayer progress".
Their RegionDetail.vue renders three of them in a row -- Prayer Coverage, Adoption Progress,
Engagement Progress -- identical in structure and different only in their numbers, their bar, and
one dark variant. That is one component with props, which is why it is here and not three copies.
★ NAMED GoalCard, NOT PrayerGoalCard, and the deviation from the name in the order is deliberate:
nothing in this component's props or CSS is about prayer. The shepherd's name described the first
instance; a shared component named after one caller mis-describes it for every later one. Flagged
in the handoff rather than decided silently.
★ THE MIDDLE IS A SLOT. What sits between the figure and the goal line differs per instance -- a
segmented bar with a legend in one, a plain bar in the other two -- so the card does not import a
bar at all. It knows about a title, a figure, and a goal; the caller supplies the rest. This is the
same rule RegionSectionCard follows for its poster wall: one caller's feature must not become every
caller's API.
★ margin-top:auto IS THE WHOLE POINT OF THE LAYOUT, not a detail. It is what makes three cards
holding different amounts of copy align their goal lines flush along the bottom edge. The full
measurement history is on the rules below; it survived a probe that measured the pair as "same".
TWO TEMPLATE DETAILS, documented here rather than in the template, because a comment between nodes
is a rendered node and this card's DOM is compared against the markup it replaced:
• the figure and its caption sit in an UNCLASSED <div>. That grouping is what makes the card's
stack margin fall between the groups rather than between a number and its own label.
• the space after </strong> in the goal line is INTERPOLATED, not written as template whitespace,
so it cannot depend on the compiler's whitespace:condense behaviour.
CONTRACT, two things the host root must provide, exactly as for RegionSectionCard:
• the DX-life token layer (--dx-radius, --dx-elevated, --dx-dark, --spacing-*, --font-size-*).
• for the Cyrillic heading switch, a `lang` attribute on an ancestor INSIDE THE SAME shadow root.
A [lang] selector cannot see the host document's attribute across a shadow boundary, so a
consumer that does not set lang on an ancestor gets the default face and nothing else changes.
No i18n is installed or required: every string arrives pre-localized, including the goal label.
-->
<script setup>
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useCardSurface } from '../../composables/useCardSurface.js'
defineProps({
/** The card heading, already localized. */
title: { type: String, default: '' },
/** The headline figure, pre-formatted by the caller, e.g. "21 / 2,107". */
value: { type: [String, Number], default: '' },
/** The caption under the figure, already localized. */
valueLabel: { type: String, default: '' },
/** The goal sentence, already localized. Pinned to the bottom of the card. */
goal: { type: String, default: '' },
/** The bold lead-in to the goal sentence, e.g. "Goal". Omitted entirely when empty. */
goalLabel: { type: String, default: '' },
/** Follows goalLabel inside the <strong>. A prop because ':' is not universal punctuation. */
labelSeparator: { type: String, default: ':' },
/** 'secondary' is their dark-on-light variant, used by the first of the three cards. */
variant: { type: String, default: 'default' },
})
// .rd-card / --secondary, shared with StatCard under one stable label.
useCardSurface()
useShadowStyles(
// ★ THEIR .stack IS margin-top, AND THAT IS LOAD-BEARING — not a stylistic detail.
// composition/_stack.scss: `.stack > * + * { margin-top: var(--stack-spacing) }`, with
// `.stack--lg` setting that to --spacing-lg. RegionDetail.vue's scoped
// `.region-goal { margin-top: auto }` then OVERRIDES the same property on the last child,
// so the goal line is pushed to the bottom and the 23.526px stack gap above it is GONE.
// This was ported as `gap`, which margin cannot override: gap AND the auto both applied,
// so every goal card carried ~23.5px of space their cards do not have. The DRIVER saw it
// ('spcing is nto ths ame', naming these three cards). Ported as their mechanism.
'.rd-goal-card{ display:flex; flex-direction:column; text-align:center; }' +
'.rd-goal-card > * + *{ margin-top:var(--spacing-lg); }' +
// ── the heading. h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl).
// ★ THIS IS A SECOND COPY OF THE PLUGIN'S .rd-h2 RULE, under this card's own class name, and the
// duplication is deliberate rather than overlooked. Using .rd-h2 itself would put two identical
// selectors in one shadow root from two different files -- the shape that drifts silently the first
// time one is tuned. The plugin keeps .rd-h2 for the one heading it still owns (its table title).
// The real fix is a shared ported-globals module beside the token layer, which is a batch of its
// own; carded, not smuggled in here.
'.rd-goal-card__title{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; }' +
// ★ 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.
// 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"] .rd-goal-card__title, [lang="ru-RU"] .rd-goal-card__title{' +
' font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// ── the figure. .font-size-4xl .font-weight-medium / .font-size-lg
// ★ NO line-height HERE. Theirs is `<p class="font-size-4xl font-weight-medium">` and the
// .font-size-4xl utility sets ONLY a font-size — so their number inherits body's 1.5. Ours forced
// 1.1, which was compensating for the base line-height we never ported (now on :host in
// dxlife-tokens.js). rega measured the cost: 24px of card height, their number ~12px lower and its
// label ~24px lower. Inheriting is the faithful port.
// ★ A CLAMP, LIKE THEIRS, AND IT RESOLVES THE NOWRAP WORRY RATHER THAN FIGHTING IT.
// utilities/_typography.scss gives .font-size-4xl
// font-size: clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))
// and ours was a flat var(--font-size-4xl). Identical at 1440 and above (both sit on the ceiling),
// but below that ours never shrank -- MEASURED against their resolved values:
// 390px viewport theirs 38.15 ours 59.60 ours 21.46 too large
// 768px viewport theirs 46.08 ours 59.60 ours 13.52 too large
// 1024px and up theirs 59.60 ours 59.60 identical
// I had reported this as unchangeable because a figure must never split across lines in a 372px
// card. That was backwards: the clamp makes the figure SMALLER exactly where the wrap was observed.
// In a 372px container 6cqw is 22.3px, below the 2xl floor, so it resolves to 38.15px against the
// 59.60px that was breaking. The clamp helps the very problem the flat value was chosen for, and
// the nowrap stays as a belt.
// cqw not vw, consistent with every other clamp in this port (the container-vs-viewport
// substitution the agent is still ruling on); at 768 that lands 42.4 against their 46.08.
'.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' +
' font-weight:500; }' +
'.rd-goal-card .rd-big{ white-space:nowrap; }' +
'.rd-big-label{ margin:0; font-size:var(--font-size-lg); }' +
// ── the goal line. .region-goal — margin-top:auto, spelled at higher specificity than the stack
// rule above so the override is unambiguous rather than depending on source order.
// ★ margin-top:auto, AND IT IS LOAD-BEARING. Theirs is RegionDetail.vue:24 `.region-goal {
// margin-top: auto }` inside `<div class="stack stack--lg | card | text-center">`, and
// composition/_stack.scss makes .stack a flex COLUMN with `> * + * { margin-top:
// var(--stack-spacing) }`. So the auto OVERRIDES that child margin and pushes the goal line to the
// BOTTOM of the card. Ours was margin:0, which left it directly under the text above it.
// The visible consequence, and why it matters for a clone: the three goal cards hold different
// amounts of copy, so theirs align their goal lines flush along the cards' bottom edge while ours
// floated at three different heights.
// FOUND BY READING THEIR MARKUP after rega's spacing probe measured this pair as "same" -- which
// it is, at 1440, because with no free space in the card the auto resolves to 0. An equal computed
// margin hid a real difference, so the probe was right and incomplete at the same time.
'.rd-goal{ margin:auto 0 0; font-size:var(--font-size-sm); }' +
'.rd-goal-card > .rd-goal{ margin-top:auto; }' +
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the UA's
// bold. The plugin's own `.rd-root strong` STAYS where it is: UupgsList renders a <strong> in the
// same shadow root and depends on it, so this is a second rule for this card's own subtree, not a
// move. Checked by reading the whole root rather than this card's markup -- a file-scoped grep
// would have called the plugin's copy dead and deleted a rule another component needs.
'.rd-goal strong{ font-weight:500; }'
, 'goal-card')
</script>
<template>
<div class="rd-card rd-goal-card" :class="{ 'rd-card--secondary': variant === 'secondary' }">
<h2 class="rd-goal-card__title">{{ title }}</h2>
<div>
<p class="rd-big">{{ value }}</p>
<p class="rd-big-label">{{ valueLabel }}</p>
</div>
<slot />
<p class="rd-goal"><strong v-if="goalLabel">{{ goalLabel }}{{ labelSeparator }}</strong>{{ goalLabel ? ' ' : '' }}{{ goal }}</p>
</div>
</template>

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 agent, 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

@ -0,0 +1,63 @@
<!--
StatCard.vue — one figure with a caption, on the DX-life card surface.
Their RegionDetail.vue renders a row of these above the map: a big number and a small grey label,
centred on a small-padding card. That is the whole component, and it is the smallest genuinely
reusable piece of the regions dashboard.
★ THE GRID IS NOT IN HERE, AND THAT IS THE POINT. The plugin's `.rd-stats` container
(grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))) stays with the CONSUMER. How many
stats there are and how they wrap is a decision belonging to the page, not to one stat; baking a
grid into a single card would make it impossible to place one on its own, or two side by side, or
in a column. So a consumer supplies its own container and puts as many StatCards in it as it has.
VALUES ARE PRE-FORMATTED BY THE CALLER. `value` is a string or number and is rendered verbatim,
because number formatting is locale-dependent and the caller already knows the locale -- the
regions plugin passes formatNumber(n) through its own i18n. This component installs no i18n and
therefore works on a host that has none.
CONTRACT: the containing shadow root must carry the DX-life token layer (DXLIFE_TOKENS in the
regions plugin) and, for the figure to render in the intended face, the Bebas Neue @font-face --
which is DOCUMENT-scoped and so must be registered on the host document, not in the shadow root.
See useDocumentFont.js. Without the face the declared fallbacks apply; nothing breaks.
-->
<script setup>
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useCardSurface } from '../../composables/useCardSurface.js'
defineProps({
/** The figure. Already formatted for the locale by the caller, e.g. "43,045,275". */
value: { type: [String, Number], default: '' },
/** The caption under the figure. Already localized by the caller. */
label: { type: String, default: '' },
})
// .rd-card / --small, shared with GoalCard under one stable label so only one copy is injected.
useCardSurface()
useShadowStyles(
// ── .region-stat: RegionDetail.vue's scoped block ──
'.rd-stat{ text-align:center; }' +
// ★ THE data-len SHRINK IS GONE, AND ITS OWN COMMENT SHOWED WHY. Their .region-stat__value is
// font-family-heading + font-size-3xl + line-height:1 and nothing else -- no such attribute exists
// in their markup. Ours was a shrink-to-fit workaround whose stated premise was "our condensed
// fallback is wider" than Bebas. That is STALE: Bebas is vendored now (assets/fonts/bebas-neue,
// glyph oracle 279 = 279 SAME), so there is no wider fallback to compensate for.
// Its other claim -- "every value DX-life itself renders keeps their size" -- was FALSIFIED by
// measurement: rega found "43,045,275" (10 chars, so it hit the 'long' bucket) rendered at
// 38.15px against their 47.68px on the same-width card, while the other three stats matched
// exactly. A rule whose own invariant is violated in practice is not protecting anything.
// Deleted WITH its mechanism -- the binding and lenBucket() too, not just the rules.
'.rd-stat__value{ margin:0; font-size:var(--font-size-3xl); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; }' +
'.rd-stat__label{ margin:var(--spacing-2xs) 0 0; font-size:var(--font-size-sm); color:var(--dx-muted); }'
, 'stat-card')
</script>
<template>
<div class="rd-card rd-card--small rd-stat">
<p class="rd-stat__value">{{ value }}</p>
<p class="rd-stat__label">{{ label }}</p>
</div>
</template>

View file

@ -0,0 +1,44 @@
/**
* useCardSurface.js — the DX-life card surface, as ONE rule set shared by every card component.
*
* WHY THIS IS A MODULE AND NOT A COPY IN EACH CARD. Vue only injects the ROOT component's <style>
* into a shadow root, so every library component that renders inside one carries its own CSS and
* injects it with useShadowStyles (see that file). StatCard and GoalCard are two components that
* need the SAME surface -- radius, elevation, padding, and the two variants -- and a copy in each is
* a rule set that will drift the first time one of them is tuned.
*
* THE STABLE LABEL IS THE WHOLE TRICK. useShadowStyles dedups on its label, so both cards (and any
* consumer that renders .rd-card markup of its own) can call useCardSurface() and exactly ONE
* <style data-shadow-styles="dxlife-card-surface"> lands per shadow root, no matter how many card
* components mount inside it. Calling it from N components is not N stylesheets.
*
* ★ THE CLASS NAMES KEEP THEIR `rd-` PREFIX, DELIBERATELY. It came from the regions-dashboard port
* and means nothing outside it. Renaming would change no pixel and would cost the traceability from
* each declaration back to the DX-life rule and the measurement that fixed it, which is the only
* reason these values can be defended. The names are internal to the components; a consumer writes
* <StatCard> and never types them. A rename is a safe later cleanup, not a prerequisite for reuse.
*
* CONTRACT: the shadow root must already carry the DX-life token layer (--dx-radius, --dx-elevated,
* --dx-text, --dx-dark, --dx-on-dark, --spacing-*). The regions plugin injects it as DXLIFE_TOKENS.
* Without it these rules resolve to nothing -- they are a skin over that scale, not a replacement.
*/
import { useShadowStyles } from './useShadowStyles.js'
export const CARD_SURFACE_CSS =
// ── .card: blocks/_cards.scss:9-21. radius --border-radius-xlg (35px);
// padding clamp(--spacing-lg, 6vw, --spacing-2xl) clamp(--spacing-lg, 6vw, --spacing-3xl).
'.rd-card{ position:relative; border-radius:var(--dx-radius);' +
' background:var(--dx-elevated); color:var(--dx-text);' +
' padding:clamp(var(--spacing-lg), 6cqw, var(--spacing-2xl)) clamp(var(--spacing-lg), 6cqw, var(--spacing-3xl)); }' +
// .card[padding-small] -> --spacing-lg flat
'.rd-card--small{ padding:var(--spacing-lg); }' +
// .card[data-variant="secondary"] -> surface-brand-dark on text-on-brand-light
'.rd-card--secondary{ background:var(--dx-dark); color:var(--dx-on-dark); }'
/**
* Injects the card surface into the containing shadow root, once, under a stable label.
* Call it at setup() time from any component that renders .rd-card markup.
*/
export function useCardSurface() {
useShadowStyles(CARD_SURFACE_CSS, 'dxlife-card-surface')
}

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

@ -0,0 +1,58 @@
/**
* useFacetAggregation.js — count rows by a facet accessor, and roll the tail into one bucket.
*
* ★ EXTRACTED FROM useDashboardAggregations (dashboard-kit landing 4c-1). That module has a split
* personality: these two functions are pure counting, while the rest of it — colorFor with its
* hardcoded religion / wagf_region / affinity_bloc branches, the religion palettes, the donut and
* bar builders — is research-specific and STAYS in plugin-designs. Only the generic half could
* move, and the facet rail needs only the generic half, which is what made this the piece blocking
* kit landing 4c.
*
* Bodies are verbatim. Nothing here knows a facet name: the caller supplies the accessor.
*/
import { tStatic } from '@map/i18n/index.js'
/**
* A deliberately NEUTRAL tone for a value this facet has no palette for — matching the tree
* legend's NEUTRAL exactly. An uncoloured slice must read as "no colour strategy for this key",
* never as a confident member of some other family. Critically it is STABLE: the same key gets
* the same neutral every time, so nothing changes colour when a filter reorders the list.
*/
export const NEUTRAL = '#c4c8c0'
export const lbl = (v) => (v === null || v === undefined ? '' : (typeof v === 'object' ? (v.label ?? v.value ?? '') : v))
/** Count rows by a key accessor; collapse the long tail into 'Other'. */
export function aggregate(rows, keyFn, topN) {
const m = new Map()
for (const r of rows) { const k = keyFn(r); if (k === null || k === undefined || k === '') continue; m.set(k, (m.get(k) || 0) + 1) }
let arr = [...m.entries()].map(([key, value]) => ({ key, value })).sort((a, b) => b.value - a.value)
if (topN && arr.length > topN) {
const top = arr.slice(0, topN)
const other = arr.slice(topN).reduce((s, x) => s + x.value, 0)
if (other > 0) top.push({ key: 'Other', value: other, other: true })
arr = top
}
return arr
}
/**
* ★ THE ROLLUP BUCKET'S KEY IS A SENTINEL; ITS LABEL IS A TRANSLATION. Keep them apart.
*
* aggregate() pushes `{ key: OTHER_KEY, other: true }` for the tail, and 'Other' was rendered
* directly — so every locale showed the English word, in the facet rail and the charts rail both.
* check:i18n-keys-exist could not see it, because there is no T() call to inspect: the string was
* never a lookup, it was a key being used as a label.
*
* The KEY must not be translated. The rail tests `key === 'Other'` to make the row inert and
* unfilterable, and the charts rail refuses to filter on it — a localized key would make the bucket
* clickable in Arabic and silently filter on a value no row carries. That is the same value/label
* trap as the religion families, arriving in generic code this time, which is worse: unfixed it
* would have been exported to every future dashboard the moment this module became kit code.
*
* So the sentinel stays English and OTHER_LABEL() projects it for a reader, next to the function
* that produces it, so the two cannot drift apart.
*/
export const OTHER_KEY = 'Other'
export const otherLabel = () => tStatic('dashboard.other') || OTHER_KEY

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

@ -0,0 +1,256 @@
{
"_meta": {
"generated_by": "library/scripts/generate-iso3-to-alpha2.mjs (bun run gen:iso3-to-alpha2)",
"purpose": "alpha-3 -> alpha-2 so Intl.DisplayNames can produce a LOCALIZED country name. Drives no data fetch.",
"not_to_be_confused_with": "library/scripts/research/out/iso3-to-iso2.json — the 176-country research spine that DOES drive fetches",
"source": "CLDR cldr-core 48.2.2, vendored at library/vendor/cldr/codeMappings.json (Unicode-3.0)",
"countries": 242,
"exceptions": {
"KOS": "XK — Kosovo has no ISO 3166-1 assignment; XK is the user-assigned code CLDR itself uses in locale ids (sr_Cyrl_XK)."
}
},
"map": {
"AFG": "AF",
"ALB": "AL",
"DZA": "DZ",
"ASM": "AS",
"AND": "AD",
"AGO": "AO",
"AIA": "AI",
"ATG": "AG",
"ARG": "AR",
"ARM": "AM",
"ABW": "AW",
"AUS": "AU",
"AUT": "AT",
"AZE": "AZ",
"BHR": "BH",
"BGD": "BD",
"BRB": "BB",
"BLR": "BY",
"BEL": "BE",
"BLZ": "BZ",
"BEN": "BJ",
"BMU": "BM",
"BTN": "BT",
"BOL": "BO",
"BES": "BQ",
"BIH": "BA",
"BWA": "BW",
"BRA": "BR",
"BRN": "BN",
"BGR": "BG",
"BFA": "BF",
"BDI": "BI",
"CPV": "CV",
"KHM": "KH",
"CMR": "CM",
"CAN": "CA",
"CYM": "KY",
"CAF": "CF",
"TCD": "TD",
"CHL": "CL",
"CHN": "CN",
"CXR": "CX",
"CCK": "CC",
"COL": "CO",
"COM": "KM",
"COK": "CK",
"CRI": "CR",
"CIV": "CI",
"HRV": "HR",
"CUB": "CU",
"CUW": "CW",
"CYP": "CY",
"DNK": "DK",
"DJI": "DJ",
"DMA": "DM",
"DOM": "DO",
"ECU": "EC",
"EGY": "EG",
"SLV": "SV",
"GNQ": "GQ",
"ERI": "ER",
"EST": "EE",
"SWZ": "SZ",
"ETH": "ET",
"FLK": "FK",
"FRO": "FO",
"FJI": "FJ",
"FIN": "FI",
"FRA": "FR",
"GUF": "GF",
"PYF": "PF",
"GAB": "GA",
"GEO": "GE",
"DEU": "DE",
"GHA": "GH",
"GIB": "GI",
"GRC": "GR",
"GRL": "GL",
"GRD": "GD",
"GLP": "GP",
"GUM": "GU",
"GTM": "GT",
"GGY": "GG",
"GIN": "GN",
"GNB": "GW",
"GUY": "GY",
"HTI": "HT",
"HND": "HN",
"HKG": "HK",
"HUN": "HU",
"ISL": "IS",
"IND": "IN",
"IDN": "ID",
"IRN": "IR",
"IRQ": "IQ",
"IRL": "IE",
"IMN": "IM",
"ISR": "IL",
"ITA": "IT",
"JAM": "JM",
"JPN": "JP",
"JEY": "JE",
"JOR": "JO",
"KAZ": "KZ",
"KEN": "KE",
"KIR": "KI",
"KOS": "XK",
"KWT": "KW",
"KGZ": "KG",
"LAO": "LA",
"LVA": "LV",
"LBN": "LB",
"LSO": "LS",
"LBR": "LR",
"LBY": "LY",
"LIE": "LI",
"LTU": "LT",
"LUX": "LU",
"MAC": "MO",
"MDG": "MG",
"MWI": "MW",
"MYS": "MY",
"MLI": "ML",
"MLT": "MT",
"MTQ": "MQ",
"MRT": "MR",
"MUS": "MU",
"MYT": "YT",
"MEX": "MX",
"MDA": "MD",
"MCO": "MC",
"MNG": "MN",
"MNE": "ME",
"MSR": "MS",
"MAR": "MA",
"MOZ": "MZ",
"MMR": "MM",
"NAM": "NA",
"NRU": "NR",
"NPL": "NP",
"NCL": "NC",
"NZL": "NZ",
"NIC": "NI",
"NER": "NE",
"NGA": "NG",
"NIU": "NU",
"NFK": "NF",
"PRK": "KP",
"MKD": "MK",
"NOR": "NO",
"OMN": "OM",
"PAK": "PK",
"PLW": "PW",
"PSE": "PS",
"PAN": "PA",
"PNG": "PG",
"PRY": "PY",
"PER": "PE",
"POL": "PL",
"PRT": "PT",
"PRI": "PR",
"QAT": "QA",
"REU": "RE",
"ROU": "RO",
"RWA": "RW",
"BLM": "BL",
"SHN": "SH",
"KNA": "KN",
"LCA": "LC",
"MAF": "MF",
"SPM": "PM",
"VCT": "VC",
"WSM": "WS",
"SMR": "SM",
"STP": "ST",
"SAU": "SA",
"SEN": "SN",
"SRB": "RS",
"SYC": "SC",
"SLE": "SL",
"SGP": "SG",
"SXM": "SX",
"SVK": "SK",
"SVN": "SI",
"SOM": "SO",
"ZAF": "ZA",
"KOR": "KR",
"SSD": "SS",
"ESP": "ES",
"LKA": "LK",
"SDN": "SD",
"SUR": "SR",
"SJM": "SJ",
"SWE": "SE",
"CHE": "CH",
"SYR": "SY",
"TWN": "TW",
"TJK": "TJ",
"TZA": "TZ",
"THA": "TH",
"BHS": "BS",
"VGB": "VG",
"CZE": "CZ",
"COD": "CD",
"FSM": "FM",
"GMB": "GM",
"MDV": "MV",
"MHL": "MH",
"NLD": "NL",
"MNP": "MP",
"PHL": "PH",
"PCN": "PN",
"COG": "CG",
"RUS": "RU",
"SLB": "SB",
"TCA": "TC",
"ARE": "AE",
"GBR": "GB",
"USA": "US",
"VIR": "VI",
"TLS": "TL",
"TGO": "TG",
"TKL": "TK",
"TON": "TO",
"TTO": "TT",
"TUN": "TN",
"TUR": "TR",
"TKM": "TM",
"TUV": "TV",
"UGA": "UG",
"UKR": "UA",
"URY": "UY",
"UZB": "UZ",
"VUT": "VU",
"VAT": "VA",
"VEN": "VE",
"VNM": "VN",
"WLF": "WF",
"ESH": "EH",
"YEM": "YE",
"ZMB": "ZM",
"ZWE": "ZW"
}
}

View file

@ -77,5 +77,6 @@
"people_praying": "الأشخاص المصلّون",
"selected_first": "المحدَّدة أولاً",
"selected_first_hint": "رفع الشعوب المحدَّدة إلى الأعلى مع الإبقاء على ترتيب الأعمدة",
"n_shown": "{n} معروض"
"n_shown": "{n} معروض",
"other": "أخرى"
}

View file

@ -131,7 +131,8 @@
"t_deaf": "فجوة الصم",
"t_resources": "الموارد حسب الشعب",
"t_languages": "اللغات حسب البلد",
"t_growth": "الوصول مقابل نمو السكان"
"t_growth": "الوصول مقابل نمو السكان",
"t_bridgereach": "لغة واحدة، كم شعبًا؟"
},
"pages": {
"listTitle": "موارد الإنجيل حسب اللغة",
@ -159,7 +160,135 @@
"Bible video": "فيديو كتابي",
"Gospel presentation": "عرض للإنجيل",
"Countries": "البلدان",
"People groups": "الشعوب"
"People groups": "الشعوب",
"People Group": "الشعب",
"Basis": "مستوى البيانات",
"Bridge ISO": "ISO اللغة الجسرية",
"Bridge language": "اللغة الجسرية",
"Film": "فيلم",
"Groups with nothing": "شعوب بلا شيء",
"People with nothing": "أشخاص بلا شيء",
"Pools checked": "المصادر المفحوصة",
"Audio": "صوتي",
"Bridge candidate": "لغة جسرية مرشحة",
"Bridge film": "فيلم باللغة الجسرية",
"Bridge scripture": "الكتاب المقدس باللغة الجسرية",
"Bridge strength": "قوة الجسر",
"Est. deaths/day": "الوفيات المقدَّرة يوميًا",
"Film link": "رابط الفيلم",
"Full Bible": "الكتاب المقدس كاملًا",
"NO RESOURCE": "لا يوجد أي مورد",
"Nobody praying": "لا أحد يصلّي",
"People": "عدد الأشخاص",
"Pools NOT checked": "المصادر التي لم تُفحص",
"Scripture link": "رابط الكتاب المقدس",
"Sign language": "لغة الإشارة",
"Status": "الحالة",
"Training": "تدريب على التلمذة",
"% with nothing": "% بلا شيء",
"ALIGNED": "الشروط مستوفاة",
"Audio link": "رابط الصوت",
"Band": "الفئة",
"Bible stories": "قصص الكتاب المقدس",
"Can already hear": "يسمعون بالفعل",
"Candidate % of country": "نسبة المرشحة في البلد %",
"Candidate ISO": "ISO المرشحة",
"Candidate is official": "المرشحة لغة رسمية",
"Carries a resource": "تحمل موردًا",
"Channel": "القناة",
"Channel source": "مصدر القناة",
"Code": "الرمز",
"Code note": "ملاحظة الرمز",
"Code unlookupable": "الرمز غير مفهرس في أي مصدر",
"Code unmatchable": "رمز يتعذر مطابقته",
"Deaf bridge": "جسر الصم",
"Deaf bridge link": "رابط جسر الصم",
"Deaf bridge publisher": "ناشر جسر الصم",
"Deaf programme in country": "برنامج للصم في البلد",
"Death rate /1000": "معدل الوفيات /1000",
"Est. deaths/yr": "الوفيات المقدَّرة سنويًا",
"Example group": "شعب على سبيل المثال",
"Film in this language": "فيلم بهذه اللغة",
"GAP": "فجوة",
"GROWING + UNREACHED": "متزايدون + غير مبلَّغين",
"Gospel audio": "صوتيات الإنجيل",
"Groups": "الشعوب",
"Groups sharing bridge": "شعوب تشترك في الجسر",
"Groups speaking": "الشعوب الناطقة",
"Groups speaking it": "شعوب تتحدثها",
"Groups the map link shows": "الشعوب التي يعرضها رابط الخريطة",
"Groups with Scripture": "شعوب لديها الكتاب المقدس",
"Groups with film": "شعوب لديها الفيلم",
"Growing & unreached": "متزايدون وغير مبلَّغين",
"Growth %/yr": "النمو %/سنة",
"Has anything": "لديه أي شيء",
"Heart film": "فيلم باللغة الأم",
"Heart language": "اللغة الأم",
"Heart scripture": "الكتاب المقدس باللغة الأم",
"Internet": "الإنترنت",
"Internet %": "الإنترنت %",
"Its population": "عدد سكانه",
"JESUS Film": "فيلم يسوع",
"Kind of gap": "نوع الفجوة",
"Kind of way in": "نوع المدخل",
"Languages": "اللغات",
"Languages here": "اللغات هنا",
"Languages they speak": "اللغات التي يتحدثونها",
"Largest group": "أكبر شعب",
"Largest groups": "أكبر الشعوب",
"Largest with nothing": "الأكبر بلا شيء",
"Lead status": "حالة الدليل الأولي",
"Link": "الرابط",
"Method": "المنهجية",
"NT": "العهد الجديد",
"Note": "ملاحظة",
"OPPORTUNITY": "فرصة",
"Of those with a prayer page": "من بين من لديهم صفحة صلاة",
"On the map": "على الخريطة",
"Open": "فتح",
"People groups here": "الشعوب هنا",
"People nobody is praying for": "أشخاص لا يصلّي أحد لأجلهم",
"People praying": "المصلّون",
"Place": "المكان",
"Placement approximate": "الموقع تقريبي",
"Pools not checked": "المصادر غير المفحوصة",
"Pray": "صلِّ",
"Primary religion": "الديانة الرئيسية",
"Province": "المقاطعة",
"Rank": "الترتيب",
"Resource": "المورد",
"Scope": "مدى التغطية",
"Scripture in this language": "الكتاب المقدس بهذه اللغة",
"Signed film": "فيلم بلغة الإشارة",
"Signed film link": "رابط فيلم لغة الإشارة",
"Source": "المصدر",
"Speakers": "المتحدثون",
"Their language": "لغتهم",
"These peoples on the map": "هذه الشعوب على الخريطة",
"Training in the bridge": "تدريب باللغة الجسرية",
"Translated by": "ترجمة",
"Type": "النوع",
"UPG population": "سكان الشعوب غير المبلَّغة",
"Urban %": "الحضر %",
"Ways in": "المداخل",
"Ways in (country-level)": "المداخل (على مستوى البلد)",
"Year": "السنة"
},
"tableTitles": {
"doxa": "شعوب دوكسا — ما المتوفر من الإنجيل، وما الذي ينقص",
"links": "موارد الإنجيل — كل رابط، ومن يصل إليه",
"nothing": "حيث لا يوجد شيء",
"countrygap": "الفجوة حسب البلد",
"translation": "أولوية الترجمة — يسمعون، ولم يُعثر على نص",
"translationcountry": "أولوية الترجمة حسب البلد — يسمعون، ولم يُعثر على نص",
"gaps": "الفجوات والفرص",
"bridges": "فرص جسور المدن",
"deaf": "فجوة الصم",
"resources": "الشعوب × موارد الإنجيل",
"languages": "اللغات حسب البلد (مرتبة) × الموارد",
"growth": "الوصول إلى الإنجيل مقابل نمو السكان",
"province": "الوصول إلى الإنجيل حسب المقاطعة — الهند والصين",
"bridgereach": "لغة واحدة، كم شعبًا — المدخل، مرتبًا حسب مدى الانتشار"
},
"charts": {
"scriptureAny": "الكتاب المقدس (أي نوع)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "قائمتي UPG 100 — قوائم الصلاة والملصقات",
"upg100WallTag": "تعبئة للصلاة",
"upg100WallIntro": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم <strong>باسم شعبهم</strong>.",
"upg100WallIntroA": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم ",
"upg100WallIntroEmph": "باسم شعبهم",
"upg100WallIntroB": ".",
"loadingNamed": "جارٍ تحميل {name}…",
"couldNotLoadNamed": "تعذّر تحميل {name}.",
"engineMissing": "لم يتم تحميل محرك الخرائط {engine}.",

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

@ -77,5 +77,6 @@
"people_praying": "Betende Personen",
"selected_first": "Ausgewählte zuerst",
"selected_first_hint": "Die ausgewählten Volksgruppen nach oben holen, die Spaltensortierung bleibt erhalten",
"n_shown": "{n} angezeigt"
"n_shown": "{n} angezeigt",
"other": "Andere"
}

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",
@ -159,7 +160,135 @@
"Bible video": "Bibelvideo",
"Gospel presentation": "Evangeliums-Darstellung",
"Countries": "Länder",
"People groups": "Volksgruppen"
"People groups": "Volksgruppen",
"People Group": "Volksgruppe",
"Basis": "Bezugsebene",
"Bridge ISO": "Brücken-ISO",
"Bridge language": "Brückensprache",
"Film": "Film",
"Groups with nothing": "Gruppen ohne etwas",
"People with nothing": "Menschen ohne etwas",
"Pools checked": "Geprüfte Quellen",
"Audio": "Audio",
"Bridge candidate": "Brückenkandidat",
"Bridge film": "Brücken-Film",
"Bridge scripture": "Brücken-Bibeltext",
"Bridge strength": "Brückenstärke",
"Est. deaths/day": "Geschätzte Todesfälle/Tag",
"Film link": "Film-Link",
"Full Bible": "Ganze Bibel",
"NO RESOURCE": "KEINE RESSOURCE",
"Nobody praying": "Niemand betet",
"People": "Menschen",
"Pools NOT checked": "NICHT geprüfte Quellen",
"Scripture link": "Bibeltext-Link",
"Sign language": "Gebärdensprache",
"Status": "Status",
"Training": "Jüngerschaftstraining",
"% with nothing": "% ohne etwas",
"ALIGNED": "ALLE BEDINGUNGEN ERFÜLLT",
"Audio link": "Audio-Link",
"Band": "Stufe",
"Bible stories": "Bibelgeschichten",
"Can already hear": "Können schon hören",
"Candidate % of country": "Kandidat % des Landes",
"Candidate ISO": "Kandidaten-ISO",
"Candidate is official": "Kandidat ist Amtssprache",
"Carries a resource": "Hat eine Ressource",
"Channel": "Kanal",
"Channel source": "Kanalquelle",
"Code": "Code",
"Code note": "Code-Hinweis",
"Code unlookupable": "Code in keiner Quelle indexiert",
"Code unmatchable": "Code nicht zuordenbar",
"Deaf bridge": "Gehörlosen-Brücke",
"Deaf bridge link": "Link Gehörlosen-Brücke",
"Deaf bridge publisher": "Herausgeber Gehörlosen-Brücke",
"Deaf programme in country": "Gehörlosenprogramm im Land",
"Death rate /1000": "Sterberate /1000",
"Est. deaths/yr": "Geschätzte Todesfälle/Jahr",
"Example group": "Beispielgruppe",
"Film in this language": "Film in dieser Sprache",
"GAP": "LÜCKE",
"GROWING + UNREACHED": "WACHSEND + UNERREICHT",
"Gospel audio": "Evangeliums-Audio",
"Groups": "Gruppen",
"Groups sharing bridge": "Gruppen mit gleicher Brücke",
"Groups speaking": "Sprechende Gruppen",
"Groups speaking it": "Gruppen, die sie sprechen",
"Groups the map link shows": "Gruppen im Karten-Link",
"Groups with Scripture": "Gruppen mit Bibeltext",
"Groups with film": "Gruppen mit Film",
"Growing & unreached": "Wachsend & unerreicht",
"Growth %/yr": "Wachstum %/Jahr",
"Has anything": "Hat etwas",
"Heart film": "Film in eigener Sprache",
"Heart language": "Eigene Sprache",
"Heart scripture": "Bibeltext in eigener Sprache",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Ihre Bevölkerung",
"JESUS Film": "JESUS Film",
"Kind of gap": "Art der Lücke",
"Kind of way in": "Art des Zugangs",
"Languages": "Sprachen",
"Languages here": "Sprachen hier",
"Languages they speak": "Ihre Sprachen",
"Largest group": "Größte Gruppe",
"Largest groups": "Größte Gruppen",
"Largest with nothing": "Größte ohne etwas",
"Lead status": "Status des Hinweises",
"Link": "Link",
"Method": "Methode",
"NT": "NT",
"Note": "Hinweis",
"OPPORTUNITY": "CHANCE",
"Of those with a prayer page": "Davon mit Gebetsseite",
"On the map": "Auf der Karte",
"Open": "Öffnen",
"People groups here": "Volksgruppen hier",
"People nobody is praying for": "Menschen, für die niemand betet",
"People praying": "Betende",
"Place": "Ort",
"Placement approximate": "Verortung ungefähr",
"Pools not checked": "Nicht geprüfte Quellen",
"Pray": "Beten",
"Primary religion": "Hauptreligion",
"Province": "Provinz",
"Rank": "Rang",
"Resource": "Ressource",
"Scope": "Umfang",
"Scripture in this language": "Bibeltext in dieser Sprache",
"Signed film": "Film in Gebärdensprache",
"Signed film link": "Link Film in Gebärdensprache",
"Source": "Quelle",
"Speakers": "Sprecher",
"Their language": "Ihre Sprache",
"These peoples on the map": "Diese Völker auf der Karte",
"Training in the bridge": "Training in der Brückensprache",
"Translated by": "Übersetzt von",
"Type": "Typ",
"UPG population": "Bevölkerung unerreichter Volksgruppen",
"Urban %": "Städtisch %",
"Ways in": "Zugänge",
"Ways in (country-level)": "Zugänge (Landesebene)",
"Year": "Jahr"
},
"tableTitles": {
"doxa": "Doxa-Volksgruppen — worin das Evangelium verfügbar ist und was fehlt",
"links": "Evangeliums-Ressourcen — jeder Link und wen er erreicht",
"nothing": "Wo nichts gefunden wurde",
"countrygap": "Die Lücke nach Land",
"translation": "Übersetzungspriorität — können hören, kein Text gefunden",
"translationcountry": "Übersetzungspriorität nach Land — können hören, kein Text gefunden",
"gaps": "Lücken und Chancen",
"bridges": "Chancen für Stadt-Brücken",
"deaf": "Die Gehörlosen-Lücke",
"resources": "Volksgruppen × Evangeliums-Ressourcen",
"languages": "Sprachen nach Land (nach Rang) x Ressourcen",
"growth": "Zugang zum Evangelium vs. Bevölkerungswachstum",
"province": "Zugang zum Evangelium nach Provinz — Indien und China",
"bridgereach": "Eine Sprache, wie viele Völker — der Zugang, nach Reichweite geordnet"
},
"charts": {
"scriptureAny": "Bibeltext (beliebig)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Meine UPG-100-Liste — Gebetslisten und Poster",
"upg100WallTag": "Gebetsmobilisierung",
"upg100WallIntro": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie <strong>mit dem Namen ihrer Volksgruppe</strong> beten.",
"upg100WallIntroA": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie ",
"upg100WallIntroEmph": "mit dem Namen ihrer Volksgruppe",
"upg100WallIntroB": " beten.",
"loadingNamed": "{name} wird geladen…",
"couldNotLoadNamed": "{name} konnte nicht geladen werden.",
"engineMissing": "Die Karten-Engine {engine} wurde nicht geladen.",

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

@ -77,5 +77,6 @@
"people_praying": "People praying",
"selected_first": "Selected first",
"selected_first_hint": "Float the selected people groups to the top, keeping your column sort",
"n_shown": "{n} shown"
"n_shown": "{n} shown",
"other": "Other"
}

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",
@ -159,7 +160,135 @@
"Bible video": "Bible video",
"Gospel presentation": "Gospel presentation",
"Countries": "Countries",
"People groups": "People groups"
"People groups": "People groups",
"People Group": "People Group",
"Basis": "Basis",
"Bridge ISO": "Bridge ISO",
"Bridge language": "Bridge language",
"Film": "Film",
"Groups with nothing": "Groups with nothing",
"People with nothing": "People with nothing",
"Pools checked": "Pools checked",
"Audio": "Audio",
"Bridge candidate": "Bridge candidate",
"Bridge film": "Bridge film",
"Bridge scripture": "Bridge scripture",
"Bridge strength": "Bridge strength",
"Est. deaths/day": "Est. deaths/day",
"Film link": "Film link",
"Full Bible": "Full Bible",
"NO RESOURCE": "NO RESOURCE",
"Nobody praying": "Nobody praying",
"People": "People",
"Pools NOT checked": "Pools NOT checked",
"Scripture link": "Scripture link",
"Sign language": "Sign language",
"Status": "Status",
"Training": "Training",
"% with nothing": "% with nothing",
"ALIGNED": "ALIGNED",
"Audio link": "Audio link",
"Band": "Band",
"Bible stories": "Bible stories",
"Can already hear": "Can already hear",
"Candidate % of country": "Candidate % of country",
"Candidate ISO": "Candidate ISO",
"Candidate is official": "Candidate is official",
"Carries a resource": "Carries a resource",
"Channel": "Channel",
"Channel source": "Channel source",
"Code": "Code",
"Code note": "Code note",
"Code unlookupable": "Code unlookupable",
"Code unmatchable": "Code unmatchable",
"Deaf bridge": "Deaf bridge",
"Deaf bridge link": "Deaf bridge link",
"Deaf bridge publisher": "Deaf bridge publisher",
"Deaf programme in country": "Deaf programme in country",
"Death rate /1000": "Death rate /1000",
"Est. deaths/yr": "Est. deaths/yr",
"Example group": "Example group",
"Film in this language": "Film in this language",
"GAP": "GAP",
"GROWING + UNREACHED": "GROWING + UNREACHED",
"Gospel audio": "Gospel audio",
"Groups": "Groups",
"Groups sharing bridge": "Groups sharing bridge",
"Groups speaking": "Groups speaking",
"Groups speaking it": "Groups speaking it",
"Groups the map link shows": "Groups the map link shows",
"Groups with Scripture": "Groups with Scripture",
"Groups with film": "Groups with film",
"Growing & unreached": "Growing & unreached",
"Growth %/yr": "Growth %/yr",
"Has anything": "Has anything",
"Heart film": "Heart film",
"Heart language": "Heart language",
"Heart scripture": "Heart scripture",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Its population",
"JESUS Film": "JESUS Film",
"Kind of gap": "Kind of gap",
"Kind of way in": "Kind of way in",
"Languages": "Languages",
"Languages here": "Languages here",
"Languages they speak": "Languages they speak",
"Largest group": "Largest group",
"Largest groups": "Largest groups",
"Largest with nothing": "Largest with nothing",
"Lead status": "Lead status",
"Link": "Link",
"Method": "Method",
"NT": "NT",
"Note": "Note",
"OPPORTUNITY": "OPPORTUNITY",
"Of those with a prayer page": "Of those with a prayer page",
"On the map": "On the map",
"Open": "Open",
"People groups here": "People groups here",
"People nobody is praying for": "People nobody is praying for",
"People praying": "People praying",
"Place": "Place",
"Placement approximate": "Placement approximate",
"Pools not checked": "Pools not checked",
"Pray": "Pray",
"Primary religion": "Primary religion",
"Province": "Province",
"Rank": "Rank",
"Resource": "Resource",
"Scope": "Scope",
"Scripture in this language": "Scripture in this language",
"Signed film": "Signed film",
"Signed film link": "Signed film link",
"Source": "Source",
"Speakers": "Speakers",
"Their language": "Their language",
"These peoples on the map": "These peoples on the map",
"Training in the bridge": "Training in the bridge",
"Translated by": "Translated by",
"Type": "Type",
"UPG population": "UPG population",
"Urban %": "Urban %",
"Ways in": "Ways in",
"Ways in (country-level)": "Ways in (country-level)",
"Year": "Year"
},
"tableTitles": {
"doxa": "Doxa people groups — what the gospel is available in, and what is missing",
"links": "Gospel Resources — every link, and who it reaches",
"nothing": "Where Nothing Was Found",
"countrygap": "The gap by country",
"translation": "Translation priority — can hear it, no text found",
"translationcountry": "Translation priority by country — can hear it, no text found",
"gaps": "Gaps and Opportunities",
"bridges": "City Bridge Opportunities",
"deaf": "The Deaf Gap",
"resources": "People Groups × Gospel Resources",
"languages": "Languages by Country (ranked) x Resources",
"growth": "Gospel Access vs Population Growth",
"province": "Gospel access by province — India and China",
"bridgereach": "One language, how many peoples — the way in, ranked by reach"
},
"charts": {
"scriptureAny": "Scripture (any)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "My UPG 100 List — Prayer Lists & Posters",
"upg100WallTag": "Prayer Mobilization",
"upg100WallIntro": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them <strong>by their people group name</strong>.",
"upg100WallIntroA": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them ",
"upg100WallIntroEmph": "by their people group name",
"upg100WallIntroB": ".",
"loadingNamed": "Loading {name}…",
"couldNotLoadNamed": "Could not load {name}.",
"engineMissing": "The {engine} map engine did not load.",

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

@ -77,5 +77,6 @@
"people_praying": "Personas que oran",
"selected_first": "Los seleccionados primero",
"selected_first_hint": "Llevar arriba los grupos de personas seleccionados, manteniendo el orden de las columnas",
"n_shown": "{n} mostrados"
"n_shown": "{n} mostrados",
"other": "Otros"
}

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",
@ -159,7 +160,135 @@
"Bible video": "Video bíblico",
"Gospel presentation": "Presentación del evangelio",
"Countries": "Países",
"People groups": "Pueblos"
"People groups": "Pueblos",
"People Group": "Pueblo",
"Basis": "Basado en",
"Bridge ISO": "ISO puente",
"Bridge language": "Lengua puente",
"Film": "Película",
"Groups with nothing": "Pueblos sin nada",
"People with nothing": "Personas sin nada",
"Pools checked": "Fuentes revisadas",
"Audio": "Audio",
"Bridge candidate": "Candidata a puente",
"Bridge film": "Película en lengua puente",
"Bridge scripture": "Escrituras en lengua puente",
"Bridge strength": "Fuerza del puente",
"Est. deaths/day": "Muertes estimadas/día",
"Film link": "Enlace de película",
"Full Bible": "Biblia completa",
"NO RESOURCE": "NINGÚN RECURSO",
"Nobody praying": "Nadie ora",
"People": "Personas",
"Pools NOT checked": "Fuentes NO revisadas",
"Scripture link": "Enlace de Escrituras",
"Sign language": "Lengua de señas",
"Status": "Estado",
"Training": "Formación de discipulado",
"% with nothing": "% sin nada",
"ALIGNED": "CONDICIONES CUMPLIDAS",
"Audio link": "Enlace de audio",
"Band": "Nivel",
"Bible stories": "Historias bíblicas",
"Can already hear": "Ya pueden oír",
"Candidate % of country": "Candidata % del país",
"Candidate ISO": "ISO candidata",
"Candidate is official": "Candidata es oficial",
"Carries a resource": "Tiene un recurso",
"Channel": "Canal",
"Channel source": "Fuente del canal",
"Code": "Código",
"Code note": "Nota del código",
"Code unlookupable": "Código sin índice en ninguna fuente",
"Code unmatchable": "Código sin correspondencia",
"Deaf bridge": "Puente para sordos",
"Deaf bridge link": "Enlace del puente para sordos",
"Deaf bridge publisher": "Editor del puente para sordos",
"Deaf programme in country": "Programa para sordos en el país",
"Death rate /1000": "Tasa de mortalidad /1000",
"Est. deaths/yr": "Muertes estimadas/año",
"Example group": "Pueblo de ejemplo",
"Film in this language": "Película en este idioma",
"GAP": "BRECHA",
"GROWING + UNREACHED": "EN CRECIMIENTO + NO ALCANZADO",
"Gospel audio": "Audio del evangelio",
"Groups": "Pueblos",
"Groups sharing bridge": "Pueblos que comparten puente",
"Groups speaking": "Pueblos hablantes",
"Groups speaking it": "Pueblos que la hablan",
"Groups the map link shows": "Pueblos que muestra el enlace del mapa",
"Groups with Scripture": "Pueblos con Escrituras",
"Groups with film": "Pueblos con película",
"Growing & unreached": "En crecimiento y no alcanzados",
"Growth %/yr": "Crecimiento %/año",
"Has anything": "Tiene algo",
"Heart film": "Película en idioma propio",
"Heart language": "Idioma propio",
"Heart scripture": "Escrituras en idioma propio",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Su población",
"JESUS Film": "Película JESÚS",
"Kind of gap": "Tipo de brecha",
"Kind of way in": "Tipo de vía de acceso",
"Languages": "Idiomas",
"Languages here": "Idiomas aquí",
"Languages they speak": "Idiomas que hablan",
"Largest group": "Pueblo más grande",
"Largest groups": "Pueblos más grandes",
"Largest with nothing": "Más grande sin nada",
"Lead status": "Estado de la pista",
"Link": "Enlace",
"Method": "Método",
"NT": "NT",
"Note": "Nota",
"OPPORTUNITY": "OPORTUNIDAD",
"Of those with a prayer page": "De los que tienen página de oración",
"On the map": "En el mapa",
"Open": "Abrir",
"People groups here": "Pueblos aquí",
"People nobody is praying for": "Personas por las que nadie ora",
"People praying": "Personas orando",
"Place": "Lugar",
"Placement approximate": "Ubicación aproximada",
"Pools not checked": "Fuentes no revisadas",
"Pray": "Orar",
"Primary religion": "Religión principal",
"Province": "Provincia",
"Rank": "Posición",
"Resource": "Recurso",
"Scope": "Extensión",
"Scripture in this language": "Escrituras en este idioma",
"Signed film": "Película en lengua de señas",
"Signed film link": "Enlace de película en lengua de señas",
"Source": "Fuente",
"Speakers": "Hablantes",
"Their language": "Su idioma",
"These peoples on the map": "Estos pueblos en el mapa",
"Training in the bridge": "Formación en la lengua puente",
"Translated by": "Traducido por",
"Type": "Tipo",
"UPG population": "Población de pueblos no alcanzados",
"Urban %": "Urbano %",
"Ways in": "Vías de acceso",
"Ways in (country-level)": "Vías de acceso (nivel país)",
"Year": "Año"
},
"tableTitles": {
"doxa": "Pueblos de Doxa — en qué está disponible el evangelio y qué falta",
"links": "Recursos del evangelio — cada enlace y a quién alcanza",
"nothing": "Donde no se encontró nada",
"countrygap": "La brecha por país",
"translation": "Prioridad de traducción — pueden oírlo, no se encontró texto",
"translationcountry": "Prioridad de traducción por país — pueden oírlo, no se encontró texto",
"gaps": "Brechas y oportunidades",
"bridges": "Oportunidades de puentes urbanos",
"deaf": "La brecha sorda",
"resources": "Pueblos × recursos del evangelio",
"languages": "Idiomas por país (clasificados) x recursos",
"growth": "Acceso al evangelio vs crecimiento de la población",
"province": "Acceso al Evangelio por provincia — India y China",
"bridgereach": "Una lengua, cuántos pueblos — la vía de acceso, ordenada por alcance"
},
"charts": {
"scriptureAny": "Escrituras (cualquiera)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Mi lista UPG 100 — Listas de oración y carteles",
"upg100WallTag": "Movilización de oración",
"upg100WallIntro": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos <strong>por el nombre de su pueblo</strong>.",
"upg100WallIntroA": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos ",
"upg100WallIntroEmph": "por el nombre de su pueblo",
"upg100WallIntroB": ".",
"loadingNamed": "Cargando {name}…",
"couldNotLoadNamed": "No se pudo cargar {name}.",
"engineMissing": "El motor de mapas {engine} no se cargó.",

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

@ -77,5 +77,6 @@
"people_praying": "Personnes qui prient",
"selected_first": "La sélection en premier",
"selected_first_hint": "Faire remonter les groupes de personnes sélectionnés, en conservant le tri des colonnes",
"n_shown": "{n} affichés"
"n_shown": "{n} affichés",
"other": "Autres"
}

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",
@ -159,7 +160,135 @@
"Bible video": "Vidéo biblique",
"Gospel presentation": "Présentation de l'Évangile",
"Countries": "Pays",
"People groups": "Peuples"
"People groups": "Peuples",
"People Group": "Peuple",
"Basis": "Basé sur",
"Bridge ISO": "ISO passerelle",
"Bridge language": "Langue passerelle",
"Film": "Film",
"Groups with nothing": "Peuples sans rien",
"People with nothing": "Personnes sans rien",
"Pools checked": "Sources consultées",
"Audio": "Audio",
"Bridge candidate": "Candidate passerelle",
"Bridge film": "Film en langue passerelle",
"Bridge scripture": "Écritures en langue passerelle",
"Bridge strength": "Force de la passerelle",
"Est. deaths/day": "Décès estimés/jour",
"Film link": "Lien du film",
"Full Bible": "Bible complète",
"NO RESOURCE": "AUCUNE RESSOURCE",
"Nobody praying": "Personne ne prie",
"People": "Personnes",
"Pools NOT checked": "Sources NON consultées",
"Scripture link": "Lien des Écritures",
"Sign language": "Langue des signes",
"Status": "Statut",
"Training": "Formation au discipulat",
"% with nothing": "% sans rien",
"ALIGNED": "CONDITIONS RÉUNIES",
"Audio link": "Lien audio",
"Band": "Niveau",
"Bible stories": "Histoires bibliques",
"Can already hear": "Entendent déjà",
"Candidate % of country": "Candidate % du pays",
"Candidate ISO": "ISO candidate",
"Candidate is official": "Candidate officielle",
"Carries a resource": "A une ressource",
"Channel": "Canal",
"Channel source": "Source du canal",
"Code": "Code",
"Code note": "Note sur le code",
"Code unlookupable": "Code absent de toute source",
"Code unmatchable": "Code sans correspondance",
"Deaf bridge": "Passerelle pour sourds",
"Deaf bridge link": "Lien passerelle pour sourds",
"Deaf bridge publisher": "Éditeur passerelle pour sourds",
"Deaf programme in country": "Programme pour sourds dans le pays",
"Death rate /1000": "Taux de mortalité /1000",
"Est. deaths/yr": "Décès estimés/an",
"Example group": "Peuple exemple",
"Film in this language": "Film dans cette langue",
"GAP": "ÉCART",
"GROWING + UNREACHED": "EN CROISSANCE + NON ATTEINT",
"Gospel audio": "Audio de l'Évangile",
"Groups": "Peuples",
"Groups sharing bridge": "Peuples partageant la passerelle",
"Groups speaking": "Peuples locuteurs",
"Groups speaking it": "Peuples qui la parlent",
"Groups the map link shows": "Peuples affichés par le lien de carte",
"Groups with Scripture": "Peuples avec Écritures",
"Groups with film": "Peuples avec film",
"Growing & unreached": "En croissance et non atteints",
"Growth %/yr": "Croissance %/an",
"Has anything": "A quelque chose",
"Heart film": "Film en langue propre",
"Heart language": "Langue propre",
"Heart scripture": "Écritures en langue propre",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Sa population",
"JESUS Film": "Film JÉSUS",
"Kind of gap": "Type d'écart",
"Kind of way in": "Type de porte d'entrée",
"Languages": "Langues",
"Languages here": "Langues ici",
"Languages they speak": "Langues parlées",
"Largest group": "Plus grand peuple",
"Largest groups": "Plus grands peuples",
"Largest with nothing": "Plus grand sans rien",
"Lead status": "Statut de la piste",
"Link": "Lien",
"Method": "Méthode",
"NT": "NT",
"Note": "Note",
"OPPORTUNITY": "OPPORTUNITÉ",
"Of those with a prayer page": "Parmi ceux avec une page de prière",
"On the map": "Sur la carte",
"Open": "Ouvrir",
"People groups here": "Peuples ici",
"People nobody is praying for": "Personnes pour qui personne ne prie",
"People praying": "Personnes qui prient",
"Place": "Lieu",
"Placement approximate": "Emplacement approximatif",
"Pools not checked": "Sources non consultées",
"Pray": "Prier",
"Primary religion": "Religion principale",
"Province": "Province",
"Rank": "Rang",
"Resource": "Ressource",
"Scope": "Étendue",
"Scripture in this language": "Écritures dans cette langue",
"Signed film": "Film en langue des signes",
"Signed film link": "Lien du film en langue des signes",
"Source": "Source",
"Speakers": "Locuteurs",
"Their language": "Leur langue",
"These peoples on the map": "Ces peuples sur la carte",
"Training in the bridge": "Formation dans la langue passerelle",
"Translated by": "Traduit par",
"Type": "Type",
"UPG population": "Population des peuples non atteints",
"Urban %": "Urbain %",
"Ways in": "Portes d'entrée",
"Ways in (country-level)": "Portes d'entrée (niveau pays)",
"Year": "Année"
},
"tableTitles": {
"doxa": "Peuples Doxa — ce qui existe de l'Évangile, et ce qui manque",
"links": "Ressources de l'Évangile — chaque lien, et qui il atteint",
"nothing": "Où rien n'a été trouvé",
"countrygap": "L'écart par pays",
"translation": "Priorité de traduction — peuvent l'entendre, aucun texte trouvé",
"translationcountry": "Priorité de traduction par pays — peuvent l'entendre, aucun texte trouvé",
"gaps": "Écarts et opportunités",
"bridges": "Opportunités de ponts urbains",
"deaf": "L'écart sourd",
"resources": "Peuples × ressources de l'Évangile",
"languages": "Langues par pays (classées) x ressources",
"growth": "Accès à l'Évangile vs croissance démographique",
"province": "Accès à l'Évangile par province — Inde et Chine",
"bridgereach": "Une langue, combien de peuples — la porte d'entrée, classée par portée"
},
"charts": {
"scriptureAny": "Écritures (toutes)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Ma liste UPG 100 — Listes de prière et affiches",
"upg100WallTag": "Mobilisation pour la prière",
"upg100WallIntro": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux <strong>par le nom de leur peuple</strong>.",
"upg100WallIntroA": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux ",
"upg100WallIntroEmph": "par le nom de leur peuple",
"upg100WallIntroB": ".",
"loadingNamed": "Chargement de {name}…",
"couldNotLoadNamed": "Impossible de charger {name}.",
"engineMissing": "Le moteur de carte {engine} ne s’est pas chargé.",

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

@ -77,5 +77,6 @@
"people_praying": "प्रार्थना करने वाले लोग",
"selected_first": "चयनित पहले",
"selected_first_hint": "चयनित जनसमूहों को ऊपर लाएँ, आपका कॉलम क्रम बनाए रखते हुए",
"n_shown": "{n} दिखाए गए"
"n_shown": "{n} दिखाए गए",
"other": "अन्य"
}

View file

@ -131,7 +131,8 @@
"t_deaf": "बधिर कमी",
"t_resources": "जनसमूह अनुसार संसाधन",
"t_languages": "देश अनुसार भाषाएँ",
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि"
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि",
"t_bridgereach": "एक भाषा, कितने जनसमूह"
},
"pages": {
"listTitle": "भाषा अनुसार सुसमाचार संसाधन",
@ -159,7 +160,135 @@
"Bible video": "बाइबल वीडियो",
"Gospel presentation": "सुसमाचार प्रस्तुति",
"Countries": "देश",
"People groups": "जनसमूह"
"People groups": "जनसमूह",
"People Group": "जनसमूह",
"Basis": "आधार स्तर",
"Bridge ISO": "सेतु ISO",
"Bridge language": "सेतु भाषा",
"Film": "फ़िल्म",
"Groups with nothing": "कुछ नहीं वाले जनसमूह",
"People with nothing": "कुछ नहीं वाले लोग",
"Pools checked": "जाँचे गए स्रोत",
"Audio": "ऑडियो",
"Bridge candidate": "संभावित सेतु",
"Bridge film": "सेतु फ़िल्म",
"Bridge scripture": "सेतु पवित्र शास्त्र",
"Bridge strength": "सेतु की मज़बूती",
"Est. deaths/day": "अनुमानित मृत्यु/दिन",
"Film link": "फ़िल्म लिंक",
"Full Bible": "पूरी बाइबल",
"NO RESOURCE": "कोई संसाधन नहीं",
"Nobody praying": "कोई प्रार्थना नहीं कर रहा",
"People": "लोग",
"Pools NOT checked": "नहीं जाँचे गए स्रोत",
"Scripture link": "पवित्र शास्त्र लिंक",
"Sign language": "सांकेतिक भाषा",
"Status": "स्थिति",
"Training": "शिष्यत्व प्रशिक्षण",
"% with nothing": "% के पास कुछ नहीं",
"ALIGNED": "सभी शर्तें पूरी",
"Audio link": "ऑडियो लिंक",
"Band": "श्रेणी",
"Bible stories": "बाइबल की कहानियाँ",
"Can already hear": "पहले से सुन सकते हैं",
"Candidate % of country": "संभावित भाषा देश का %",
"Candidate ISO": "संभावित ISO",
"Candidate is official": "संभावित आधिकारिक है",
"Carries a resource": "संसाधन उपलब्ध",
"Channel": "चैनल",
"Channel source": "चैनल स्रोत",
"Code": "कोड",
"Code note": "कोड टिप्पणी",
"Code unlookupable": "कोड किसी स्रोत में नहीं",
"Code unmatchable": "कोड मेल नहीं खाता",
"Deaf bridge": "बधिर सेतु",
"Deaf bridge link": "बधिर सेतु लिंक",
"Deaf bridge publisher": "बधिर सेतु प्रकाशक",
"Deaf programme in country": "देश में बधिर कार्यक्रम",
"Death rate /1000": "मृत्यु दर /1000",
"Est. deaths/yr": "अनुमानित मृत्यु/वर्ष",
"Example group": "उदाहरण जनसमूह",
"Film in this language": "इस भाषा में फ़िल्म",
"GAP": "कमी",
"GROWING + UNREACHED": "बढ़ते + अनपहुँचे",
"Gospel audio": "सुसमाचार ऑडियो",
"Groups": "जनसमूह",
"Groups sharing bridge": "सेतु साझा करने वाले जनसमूह",
"Groups speaking": "बोलने वाले जनसमूह",
"Groups speaking it": "इसे बोलने वाले जनसमूह",
"Groups the map link shows": "मानचित्र लिंक में दिखने वाले जनसमूह",
"Groups with Scripture": "पवित्र शास्त्र वाले जनसमूह",
"Groups with film": "फ़िल्म वाले जनसमूह",
"Growing & unreached": "बढ़ते और अनपहुँचे",
"Growth %/yr": "वृद्धि %/वर्ष",
"Has anything": "कुछ उपलब्ध",
"Heart film": "अपनी भाषा में फ़िल्म",
"Heart language": "अपनी भाषा",
"Heart scripture": "अपनी भाषा में पवित्र शास्त्र",
"Internet": "इंटरनेट",
"Internet %": "इंटरनेट %",
"Its population": "उसकी जनसंख्या",
"JESUS Film": "यीशु फ़िल्म",
"Kind of gap": "कमी का प्रकार",
"Kind of way in": "रास्ते का प्रकार",
"Languages": "भाषाएँ",
"Languages here": "यहाँ की भाषाएँ",
"Languages they speak": "उनकी भाषाएँ",
"Largest group": "सबसे बड़ा जनसमूह",
"Largest groups": "सबसे बड़े जनसमूह",
"Largest with nothing": "कुछ नहीं वाला सबसे बड़ा",
"Lead status": "सुराग की स्थिति",
"Link": "लिंक",
"Method": "विधि",
"NT": "नया नियम",
"Note": "टिप्पणी",
"OPPORTUNITY": "अवसर",
"Of those with a prayer page": "प्रार्थना पेज वालों में से",
"On the map": "मानचित्र पर",
"Open": "खोलें",
"People groups here": "यहाँ के जनसमूह",
"People nobody is praying for": "जिनके लिए कोई प्रार्थना नहीं कर रहा",
"People praying": "प्रार्थना करने वाले",
"Place": "स्थान",
"Placement approximate": "स्थान अनुमानित",
"Pools not checked": "नहीं जाँचे गए स्रोत",
"Pray": "प्रार्थना करें",
"Primary religion": "मुख्य धर्म",
"Province": "प्रांत",
"Rank": "क्रम",
"Resource": "संसाधन",
"Scope": "विस्तार",
"Scripture in this language": "इस भाषा में पवित्र शास्त्र",
"Signed film": "सांकेतिक फ़िल्म",
"Signed film link": "सांकेतिक फ़िल्म लिंक",
"Source": "स्रोत",
"Speakers": "बोलने वाले",
"Their language": "उनकी भाषा",
"These peoples on the map": "मानचित्र पर ये जनसमूह",
"Training in the bridge": "सेतु भाषा में प्रशिक्षण",
"Translated by": "अनुवादक",
"Type": "प्रकार",
"UPG population": "अनपहुँचे जनसमूहों की जनसंख्या",
"Urban %": "शहरी %",
"Ways in": "रास्ते",
"Ways in (country-level)": "रास्ते (देश स्तर)",
"Year": "वर्ष"
},
"tableTitles": {
"doxa": "दोक्सा जनसमूह — सुसमाचार किसमें उपलब्ध है, और क्या कमी है",
"links": "सुसमाचार संसाधन — हर लिंक, और वह किस तक पहुँचता है",
"nothing": "जहाँ कुछ नहीं मिला",
"countrygap": "देश अनुसार कमी",
"translation": "अनुवाद प्राथमिकता — सुन सकते हैं, कोई पाठ नहीं मिला",
"translationcountry": "देश अनुसार अनुवाद प्राथमिकता — सुन सकते हैं, कोई पाठ नहीं मिला",
"gaps": "कमियाँ और अवसर",
"bridges": "नगर सेतु अवसर",
"deaf": "बधिर कमी",
"resources": "जनसमूह × सुसमाचार संसाधन",
"languages": "देश अनुसार भाषाएँ (क्रमबद्ध) x संसाधन",
"growth": "सुसमाचार पहुँच बनाम जनसंख्या वृद्धि",
"province": "प्रांत के अनुसार सुसमाचार तक पहुँच — भारत और चीन",
"bridgereach": "एक भाषा, कितने जनसमूह — पहुँचने का रास्ता, पहुँच के क्रम में"
},
"charts": {
"scriptureAny": "पवित्र शास्त्र (कोई भी)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "मेरी UPG 100 सूची — प्रार्थना सूचियाँ और पोस्टर",
"upg100WallTag": "प्रार्थना लामबंदी",
"upg100WallIntro": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए <strong>उनके जनसमूह के नाम से</strong> प्रार्थना करते हुए।",
"upg100WallIntroA": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए ",
"upg100WallIntroEmph": "उनके जनसमूह के नाम से",
"upg100WallIntroB": " प्रार्थना करते हुए।",
"loadingNamed": "{name} लोड हो रहा है…",
"couldNotLoadNamed": "{name} लोड नहीं हो सका।",
"engineMissing": "{engine} नक्शा इंजन लोड नहीं हुआ।",

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

@ -77,5 +77,6 @@
"people_praying": "Persone che pregano",
"selected_first": "Prima i selezionati",
"selected_first_hint": "Porta in alto i gruppi di persone selezionati, mantenendo l’ordinamento delle colonne",
"n_shown": "{n} mostrati"
"n_shown": "{n} mostrati",
"other": "Altri"
}

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",
@ -159,7 +160,135 @@
"Bible video": "Video biblico",
"Gospel presentation": "Presentazione del Vangelo",
"Countries": "Paesi",
"People groups": "Popoli"
"People groups": "Popoli",
"People Group": "Popolo",
"Basis": "Basato su",
"Bridge ISO": "ISO ponte",
"Bridge language": "Lingua ponte",
"Film": "Film",
"Groups with nothing": "Popoli senza nulla",
"People with nothing": "Persone senza nulla",
"Pools checked": "Fonti controllate",
"Audio": "Audio",
"Bridge candidate": "Candidata ponte",
"Bridge film": "Film in lingua ponte",
"Bridge scripture": "Scritture in lingua ponte",
"Bridge strength": "Forza del ponte",
"Est. deaths/day": "Decessi stimati/giorno",
"Film link": "Link al film",
"Full Bible": "Bibbia completa",
"NO RESOURCE": "NESSUNA RISORSA",
"Nobody praying": "Nessuno prega",
"People": "Persone",
"Pools NOT checked": "Fonti NON controllate",
"Scripture link": "Link alle Scritture",
"Sign language": "Lingua dei segni",
"Status": "Stato",
"Training": "Formazione al discepolato",
"% with nothing": "% senza nulla",
"ALIGNED": "CONDIZIONI SODDISFATTE",
"Audio link": "Link audio",
"Band": "Livello",
"Bible stories": "Storie bibliche",
"Can already hear": "Già possono ascoltare",
"Candidate % of country": "Candidata % del paese",
"Candidate ISO": "ISO candidata",
"Candidate is official": "Candidata ufficiale",
"Carries a resource": "Ha una risorsa",
"Channel": "Canale",
"Channel source": "Fonte del canale",
"Code": "Codice",
"Code note": "Nota sul codice",
"Code unlookupable": "Codice assente da ogni fonte",
"Code unmatchable": "Codice senza corrispondenza",
"Deaf bridge": "Ponte per sordi",
"Deaf bridge link": "Link ponte per sordi",
"Deaf bridge publisher": "Editore ponte per sordi",
"Deaf programme in country": "Programma per sordi nel paese",
"Death rate /1000": "Tasso di mortalità /1000",
"Est. deaths/yr": "Decessi stimati/anno",
"Example group": "Popolo di esempio",
"Film in this language": "Film in questa lingua",
"GAP": "DIVARIO",
"GROWING + UNREACHED": "IN CRESCITA + NON RAGGIUNTO",
"Gospel audio": "Audio del Vangelo",
"Groups": "Popoli",
"Groups sharing bridge": "Popoli che condividono il ponte",
"Groups speaking": "Popoli parlanti",
"Groups speaking it": "Popoli che la parlano",
"Groups the map link shows": "Popoli mostrati dal link alla mappa",
"Groups with Scripture": "Popoli con Scritture",
"Groups with film": "Popoli con film",
"Growing & unreached": "In crescita e non raggiunti",
"Growth %/yr": "Crescita %/anno",
"Has anything": "Ha qualcosa",
"Heart film": "Film nella lingua propria",
"Heart language": "Lingua propria",
"Heart scripture": "Scritture nella lingua propria",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Sua popolazione",
"JESUS Film": "Film GESÙ",
"Kind of gap": "Tipo di divario",
"Kind of way in": "Tipo di via d'accesso",
"Languages": "Lingue",
"Languages here": "Lingue qui",
"Languages they speak": "Lingue parlate",
"Largest group": "Popolo più grande",
"Largest groups": "Popoli più grandi",
"Largest with nothing": "Più grande senza nulla",
"Lead status": "Stato della pista",
"Link": "Link",
"Method": "Metodo",
"NT": "NT",
"Note": "Nota",
"OPPORTUNITY": "OPPORTUNITÀ",
"Of those with a prayer page": "Di quelli con una pagina di preghiera",
"On the map": "Sulla mappa",
"Open": "Apri",
"People groups here": "Popoli qui",
"People nobody is praying for": "Persone per cui nessuno prega",
"People praying": "Persone che pregano",
"Place": "Luogo",
"Placement approximate": "Posizione approssimativa",
"Pools not checked": "Fonti non controllate",
"Pray": "Prega",
"Primary religion": "Religione principale",
"Province": "Provincia",
"Rank": "Classifica",
"Resource": "Risorsa",
"Scope": "Estensione",
"Scripture in this language": "Scritture in questa lingua",
"Signed film": "Film in lingua dei segni",
"Signed film link": "Link film in lingua dei segni",
"Source": "Fonte",
"Speakers": "Parlanti",
"Their language": "La loro lingua",
"These peoples on the map": "Questi popoli sulla mappa",
"Training in the bridge": "Formazione nella lingua ponte",
"Translated by": "Tradotto da",
"Type": "Tipo",
"UPG population": "Popolazione dei popoli non raggiunti",
"Urban %": "Urbano %",
"Ways in": "Vie d'accesso",
"Ways in (country-level)": "Vie d'accesso (livello paese)",
"Year": "Anno"
},
"tableTitles": {
"doxa": "Popoli Doxa — in cosa è disponibile il Vangelo, e cosa manca",
"links": "Risorse del Vangelo — ogni link, e chi raggiunge",
"nothing": "Dove non si è trovato nulla",
"countrygap": "Il divario per paese",
"translation": "Priorità di traduzione — possono ascoltarlo, nessun testo trovato",
"translationcountry": "Priorità di traduzione per paese — possono ascoltarlo, nessun testo trovato",
"gaps": "Divari e opportunità",
"bridges": "Opportunità di ponti urbani",
"deaf": "Il divario sordo",
"resources": "Popoli × risorse del Vangelo",
"languages": "Lingue per paese (in classifica) x risorse",
"growth": "Accesso al Vangelo vs crescita della popolazione",
"province": "Accesso al Vangelo per provincia — India e Cina",
"bridgereach": "Una lingua, quanti popoli — la via d'accesso, ordinata per portata"
},
"charts": {
"scriptureAny": "Scritture (qualsiasi)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "La mia lista UPG 100 — Liste di preghiera e poster",
"upg100WallTag": "Mobilitazione alla preghiera",
"upg100WallIntro": "Distribuisci il compito della preghiera in gruppi di cinquanta e di cento — come Gesù sfamò i 5.000 (Luca 9:14). Ogni poster è una lista di 100 che puoi pregare, stampare e appendere al muro — pregando per loro <strong>con il nome del loro popolo</strong>.",
"upg100WallIntroA": "Distribuisci il compito della preghiera in gruppi di cinquanta e di cento — come Gesù sfamò i 5.000 (Luca 9:14). Ogni poster è una lista di 100 che puoi pregare, stampare e appendere al muro — pregando per loro ",
"upg100WallIntroEmph": "con il nome del loro popolo",
"upg100WallIntroB": ".",
"loadingNamed": "Caricamento di {name}…",
"couldNotLoadNamed": "Impossibile caricare {name}.",
"engineMissing": "Il motore di mappa {engine} non è stato caricato.",

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

@ -77,5 +77,6 @@
"people_praying": "Pessoas a orar",
"selected_first": "Os selecionados primeiro",
"selected_first_hint": "Trazer para cima os grupos de pessoas selecionados, mantendo a ordenação das colunas",
"n_shown": "{n} apresentados"
"n_shown": "{n} apresentados",
"other": "Outros"
}

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",
@ -159,7 +160,135 @@
"Bible video": "Vídeo bíblico",
"Gospel presentation": "Apresentação do evangelho",
"Countries": "Países",
"People groups": "Povos"
"People groups": "Povos",
"People Group": "Povo",
"Basis": "Com base em",
"Bridge ISO": "ISO da ponte",
"Bridge language": "Língua-ponte",
"Film": "Filme",
"Groups with nothing": "Povos sem nada",
"People with nothing": "Pessoas sem nada",
"Pools checked": "Fontes verificadas",
"Audio": "Áudio",
"Bridge candidate": "Candidata a ponte",
"Bridge film": "Filme na língua-ponte",
"Bridge scripture": "Escrituras na língua-ponte",
"Bridge strength": "Força da ponte",
"Est. deaths/day": "Mortes estimadas/dia",
"Film link": "Link do filme",
"Full Bible": "Bíblia completa",
"NO RESOURCE": "NENHUM RECURSO",
"Nobody praying": "Ninguém orando",
"People": "Pessoas",
"Pools NOT checked": "Fontes NÃO verificadas",
"Scripture link": "Link das Escrituras",
"Sign language": "Língua de sinais",
"Status": "Status",
"Training": "Treinamento de discipulado",
"% with nothing": "% sem nada",
"ALIGNED": "CONDIÇÕES ATENDIDAS",
"Audio link": "Link do áudio",
"Band": "Nível",
"Bible stories": "Histórias bíblicas",
"Can already hear": "Já podem ouvir",
"Candidate % of country": "Candidata % do país",
"Candidate ISO": "ISO da candidata",
"Candidate is official": "Candidata é oficial",
"Carries a resource": "Tem um recurso",
"Channel": "Canal",
"Channel source": "Fonte do canal",
"Code": "Código",
"Code note": "Nota do código",
"Code unlookupable": "Código em nenhuma fonte",
"Code unmatchable": "Código sem correspondência",
"Deaf bridge": "Ponte para surdos",
"Deaf bridge link": "Link da ponte para surdos",
"Deaf bridge publisher": "Editora da ponte para surdos",
"Deaf programme in country": "Programa para surdos no país",
"Death rate /1000": "Taxa de mortalidade /1000",
"Est. deaths/yr": "Mortes estimadas/ano",
"Example group": "Povo de exemplo",
"Film in this language": "Filme neste idioma",
"GAP": "LACUNA",
"GROWING + UNREACHED": "EM CRESCIMENTO + NÃO ALCANÇADO",
"Gospel audio": "Áudio do evangelho",
"Groups": "Povos",
"Groups sharing bridge": "Povos que compartilham a ponte",
"Groups speaking": "Povos falantes",
"Groups speaking it": "Povos que a falam",
"Groups the map link shows": "Povos mostrados pelo link do mapa",
"Groups with Scripture": "Povos com Escrituras",
"Groups with film": "Povos com filme",
"Growing & unreached": "Em crescimento e não alcançados",
"Growth %/yr": "Crescimento %/ano",
"Has anything": "Tem algo",
"Heart film": "Filme no idioma próprio",
"Heart language": "Idioma próprio",
"Heart scripture": "Escrituras no idioma próprio",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Sua população",
"JESUS Film": "Filme JESUS",
"Kind of gap": "Tipo de lacuna",
"Kind of way in": "Tipo de porta de entrada",
"Languages": "Idiomas",
"Languages here": "Idiomas aqui",
"Languages they speak": "Idiomas que falam",
"Largest group": "Maior povo",
"Largest groups": "Maiores povos",
"Largest with nothing": "Maior sem nada",
"Lead status": "Status da pista",
"Link": "Link",
"Method": "Método",
"NT": "NT",
"Note": "Nota",
"OPPORTUNITY": "OPORTUNIDADE",
"Of those with a prayer page": "Dos que têm página de oração",
"On the map": "No mapa",
"Open": "Abrir",
"People groups here": "Povos aqui",
"People nobody is praying for": "Pessoas por quem ninguém ora",
"People praying": "Pessoas orando",
"Place": "Local",
"Placement approximate": "Localização aproximada",
"Pools not checked": "Fontes não verificadas",
"Pray": "Orar",
"Primary religion": "Religião principal",
"Province": "Província",
"Rank": "Posição",
"Resource": "Recurso",
"Scope": "Abrangência",
"Scripture in this language": "Escrituras neste idioma",
"Signed film": "Filme em língua de sinais",
"Signed film link": "Link do filme em língua de sinais",
"Source": "Fonte",
"Speakers": "Falantes",
"Their language": "Seu idioma",
"These peoples on the map": "Estes povos no mapa",
"Training in the bridge": "Treinamento na língua-ponte",
"Translated by": "Traduzido por",
"Type": "Tipo",
"UPG population": "População dos povos não alcançados",
"Urban %": "Urbano %",
"Ways in": "Portas de entrada",
"Ways in (country-level)": "Portas de entrada (nível país)",
"Year": "Ano"
},
"tableTitles": {
"doxa": "Povos Doxa — em que o evangelho está disponível, e o que falta",
"links": "Recursos do evangelho — cada link, e quem ele alcança",
"nothing": "Onde nada foi encontrado",
"countrygap": "A lacuna por país",
"translation": "Prioridade de tradução — podem ouvir, nenhum texto encontrado",
"translationcountry": "Prioridade de tradução por país — podem ouvir, nenhum texto encontrado",
"gaps": "Lacunas e oportunidades",
"bridges": "Oportunidades de pontes urbanas",
"deaf": "A lacuna surda",
"resources": "Povos × recursos do evangelho",
"languages": "Idiomas por país (classificados) x recursos",
"growth": "Acesso ao evangelho vs crescimento populacional",
"province": "Acesso ao Evangelho por província — Índia e China",
"bridgereach": "Uma língua, quantos povos — a porta de entrada, ordenada por alcance"
},
"charts": {
"scriptureAny": "Escrituras (qualquer)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "A minha lista UPG 100 — Listas de oração e cartazes",
"upg100WallTag": "Mobilização de oração",
"upg100WallIntro": "Reparta a tarefa da oração em grupos de cinquenta e de cem — como Jesus alimentou os 5.000 (Lucas 9:14). Cada cartaz é uma lista de 100 que pode orar, imprimir e pôr na parede — orando por eles <strong>pelo nome do seu povo</strong>.",
"upg100WallIntroA": "Reparta a tarefa da oração em grupos de cinquenta e de cem — como Jesus alimentou os 5.000 (Lucas 9:14). Cada cartaz é uma lista de 100 que pode orar, imprimir e pôr na parede — orando por eles ",
"upg100WallIntroEmph": "pelo nome do seu povo",
"upg100WallIntroB": ".",
"loadingNamed": "Carregando {name}…",
"couldNotLoadNamed": "Não foi possível carregar {name}.",
"engineMissing": "O motor de mapa {engine} não foi carregado.",

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

@ -77,5 +77,6 @@
"people_praying": "Persoane care se roagă",
"selected_first": "Cele selectate primele",
"selected_first_hint": "Ridică grupurile de oameni selectate în partea de sus, păstrând sortarea coloanelor",
"n_shown": "{n} afișate"
"n_shown": "{n} afișate",
"other": "Altele"
}

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",
@ -159,7 +160,135 @@
"Bible video": "Video biblic",
"Gospel presentation": "Prezentarea Evangheliei",
"Countries": "Țări",
"People groups": "Popoare"
"People groups": "Popoare",
"People Group": "Popor",
"Basis": "Pe baza",
"Bridge ISO": "ISO punte",
"Bridge language": "Limbă-punte",
"Film": "Film",
"Groups with nothing": "Popoare fără nimic",
"People with nothing": "Persoane fără nimic",
"Pools checked": "Surse verificate",
"Audio": "Audio",
"Bridge candidate": "Candidată punte",
"Bridge film": "Film în limba-punte",
"Bridge scripture": "Scripturi în limba-punte",
"Bridge strength": "Puterea punții",
"Est. deaths/day": "Decese estimate/zi",
"Film link": "Link film",
"Full Bible": "Biblia completă",
"NO RESOURCE": "NICIO RESURSĂ",
"Nobody praying": "Nimeni nu se roagă",
"People": "Persoane",
"Pools NOT checked": "Surse NEVERIFICATE",
"Scripture link": "Link Scripturi",
"Sign language": "Limba semnelor",
"Status": "Stare",
"Training": "Instruire în ucenicie",
"% with nothing": "% fără nimic",
"ALIGNED": "CONDIȚII ÎNDEPLINITE",
"Audio link": "Link audio",
"Band": "Nivel",
"Bible stories": "Povestiri biblice",
"Can already hear": "Pot deja auzi",
"Candidate % of country": "Candidată % din țară",
"Candidate ISO": "ISO candidată",
"Candidate is official": "Candidată e oficială",
"Carries a resource": "Are o resursă",
"Channel": "Canal",
"Channel source": "Sursa canalului",
"Code": "Cod",
"Code note": "Notă cod",
"Code unlookupable": "Cod absent din orice sursă",
"Code unmatchable": "Cod fără corespondență",
"Deaf bridge": "Punte pentru surzi",
"Deaf bridge link": "Link punte pentru surzi",
"Deaf bridge publisher": "Editor punte pentru surzi",
"Deaf programme in country": "Program pentru surzi în țară",
"Death rate /1000": "Rata mortalității /1000",
"Est. deaths/yr": "Decese estimate/an",
"Example group": "Popor exemplu",
"Film in this language": "Film în această limbă",
"GAP": "GOL",
"GROWING + UNREACHED": "ÎN CREȘTERE + NEATINS",
"Gospel audio": "Audio cu Evanghelia",
"Groups": "Popoare",
"Groups sharing bridge": "Popoare cu aceeași punte",
"Groups speaking": "Popoare vorbitoare",
"Groups speaking it": "Popoare care o vorbesc",
"Groups the map link shows": "Popoare afișate de linkul hărții",
"Groups with Scripture": "Popoare cu Scripturi",
"Groups with film": "Popoare cu film",
"Growing & unreached": "În creștere și neatinse",
"Growth %/yr": "Creștere %/an",
"Has anything": "Are ceva",
"Heart film": "Film în limba proprie",
"Heart language": "Limba proprie",
"Heart scripture": "Scripturi în limba proprie",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Populația sa",
"JESUS Film": "Filmul ISUS",
"Kind of gap": "Tipul golului",
"Kind of way in": "Tipul căii de acces",
"Languages": "Limbi",
"Languages here": "Limbi aici",
"Languages they speak": "Limbile vorbite",
"Largest group": "Cel mai mare popor",
"Largest groups": "Cele mai mari popoare",
"Largest with nothing": "Cel mai mare fără nimic",
"Lead status": "Stadiul pistei",
"Link": "Link",
"Method": "Metodă",
"NT": "NT",
"Note": "Notă",
"OPPORTUNITY": "OPORTUNITATE",
"Of those with a prayer page": "Dintre cele cu pagină de rugăciune",
"On the map": "Pe hartă",
"Open": "Deschide",
"People groups here": "Popoare aici",
"People nobody is praying for": "Persoane pentru care nu se roagă nimeni",
"People praying": "Persoane care se roagă",
"Place": "Loc",
"Placement approximate": "Amplasare aproximativă",
"Pools not checked": "Surse neverificate",
"Pray": "Roagă-te",
"Primary religion": "Religia principală",
"Province": "Provincie",
"Rank": "Rang",
"Resource": "Resursă",
"Scope": "Acoperire",
"Scripture in this language": "Scripturi în această limbă",
"Signed film": "Film în limba semnelor",
"Signed film link": "Link film în limba semnelor",
"Source": "Sursă",
"Speakers": "Vorbitori",
"Their language": "Limba lor",
"These peoples on the map": "Aceste popoare pe hartă",
"Training in the bridge": "Instruire în limba-punte",
"Translated by": "Tradus de",
"Type": "Tip",
"UPG population": "Populația popoarelor neatinse",
"Urban %": "Urban %",
"Ways in": "Căi de acces",
"Ways in (country-level)": "Căi de acces (la nivel de țară)",
"Year": "An"
},
"tableTitles": {
"doxa": "Popoare Doxa — în ce este disponibilă Evanghelia și ce lipsește",
"links": "Resurse ale Evangheliei — fiecare link și pe cine atinge",
"nothing": "Unde nu s-a găsit nimic",
"countrygap": "Golul pe țări",
"translation": "Prioritate de traducere — pot auzi, niciun text găsit",
"translationcountry": "Prioritate de traducere pe țări — pot auzi, niciun text găsit",
"gaps": "Goluri și oportunități",
"bridges": "Oportunități de punți urbane",
"deaf": "Golul surd",
"resources": "Popoare × resurse ale Evangheliei",
"languages": "Limbi pe țări (clasate) x resurse",
"growth": "Accesul la Evanghelie vs creșterea populației",
"province": "Accesul la Evanghelie pe provincii — India și China",
"bridgereach": "O limbă, câte popoare — calea de acces, ordonată după acoperire"
},
"charts": {
"scriptureAny": "Scripturi (oricare)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Lista mea UPG 100 — Liste de rugăciune și afișe",
"upg100WallTag": "Mobilizare pentru rugăciune",
"upg100WallIntro": "Împarte sarcina rugăciunii în grupuri de câte cincizeci și o sută — așa cum Isus a hrănit cei 5.000 (Luca 9:14). Fiecare afiș este o listă de 100 pe care o poți ruga, tipări și pune pe perete — rugându-te pentru ei <strong>pe numele poporului lor</strong>.",
"upg100WallIntroA": "Împarte sarcina rugăciunii în grupuri de câte cincizeci și o sută — așa cum Isus a hrănit cei 5.000 (Luca 9:14). Fiecare afiș este o listă de 100 pe care o poți ruga, tipări și pune pe perete — rugându-te pentru ei ",
"upg100WallIntroEmph": "pe numele poporului lor",
"upg100WallIntroB": ".",
"loadingNamed": "Se încarcă {name}…",
"couldNotLoadNamed": "Nu s-a putut încărca {name}.",
"engineMissing": "Motorul de hartă {engine} nu s-a încărcat.",

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

@ -77,5 +77,6 @@
"people_praying": "Молящиеся",
"selected_first": "Выбранные сначала",
"selected_first_hint": "Поднять выбранные народы наверх, сохранив сортировку столбцов",
"n_shown": "{n} показано"
"n_shown": "{n} показано",
"other": "Другое"
}

View file

@ -131,7 +131,8 @@
"t_deaf": "Пробел у глухих",
"t_resources": "Ресурсы по народам",
"t_languages": "Языки по странам",
"t_growth": "Доступ и рост населения"
"t_growth": "Доступ и рост населения",
"t_bridgereach": "Один язык — сколько народов"
},
"pages": {
"listTitle": "Ресурсы Евангелия по языкам",
@ -159,7 +160,135 @@
"Bible video": "Библейское видео",
"Gospel presentation": "Изложение Евангелия",
"Countries": "Страны",
"People groups": "Народы"
"People groups": "Народы",
"People Group": "Народ",
"Basis": "Уровень данных",
"Bridge ISO": "ISO посредника",
"Bridge language": "Язык-посредник",
"Film": "Фильм",
"Groups with nothing": "Народов без ресурсов",
"People with nothing": "Людей без ресурсов",
"Pools checked": "Проверенные источники",
"Audio": "Аудио",
"Bridge candidate": "Кандидат в посредники",
"Bridge film": "Фильм на языке-посреднике",
"Bridge scripture": "Писание на языке-посреднике",
"Bridge strength": "Сила посредника",
"Est. deaths/day": "Оценка смертей в день",
"Film link": "Ссылка на фильм",
"Full Bible": "Полная Библия",
"NO RESOURCE": "НЕТ РЕСУРСОВ",
"Nobody praying": "Никто не молится",
"People": "Людей",
"Pools NOT checked": "НЕпроверенные источники",
"Scripture link": "Ссылка на Писание",
"Sign language": "Жестовый язык",
"Status": "Статус",
"Training": "Обучение ученичеству",
"% with nothing": "% без ресурсов",
"ALIGNED": "УСЛОВИЯ ВЫПОЛНЕНЫ",
"Audio link": "Ссылка на аудио",
"Band": "Уровень",
"Bible stories": "Библейские истории",
"Can already hear": "Уже могут услышать",
"Candidate % of country": "Кандидат, % страны",
"Candidate ISO": "ISO кандидата",
"Candidate is official": "Кандидат официальный",
"Carries a resource": "Есть ресурс",
"Channel": "Канал",
"Channel source": "Источник канала",
"Code": "Код",
"Code note": "Примечание к коду",
"Code unlookupable": "Кода нет ни в одном источнике",
"Code unmatchable": "Код без соответствия",
"Deaf bridge": "Посредник для глухих",
"Deaf bridge link": "Ссылка: посредник для глухих",
"Deaf bridge publisher": "Издатель: посредник для глухих",
"Deaf programme in country": "Программа для глухих в стране",
"Death rate /1000": "Смертность /1000",
"Est. deaths/yr": "Оценка смертей в год",
"Example group": "Пример народа",
"Film in this language": "Фильм на этом языке",
"GAP": "ПРОБЕЛ",
"GROWING + UNREACHED": "РАСТУЩИЕ + НЕДОСТИГНУТЫЕ",
"Gospel audio": "Аудио с Евангелием",
"Groups": "Народов",
"Groups sharing bridge": "Народов с тем же посредником",
"Groups speaking": "Говорящих народов",
"Groups speaking it": "Народов, говорящих на нём",
"Groups the map link shows": "Народы по ссылке на карту",
"Groups with Scripture": "Народов с Писанием",
"Groups with film": "Народов с фильмом",
"Growing & unreached": "Растущие и недостигнутые",
"Growth %/yr": "Рост %/год",
"Has anything": "Есть что-то",
"Heart film": "Фильм на родном языке",
"Heart language": "Родной язык",
"Heart scripture": "Писание на родном языке",
"Internet": "Интернет",
"Internet %": "Интернет %",
"Its population": "Его численность",
"JESUS Film": "Фильм «Иисус»",
"Kind of gap": "Вид пробела",
"Kind of way in": "Вид пути",
"Languages": "Языки",
"Languages here": "Языки здесь",
"Languages they speak": "Их языки",
"Largest group": "Крупнейший народ",
"Largest groups": "Крупнейшие народы",
"Largest with nothing": "Крупнейший без ресурсов",
"Lead status": "Статус наводки",
"Link": "Ссылка",
"Method": "Метод",
"NT": "НЗ",
"Note": "Примечание",
"OPPORTUNITY": "ВОЗМОЖНОСТЬ",
"Of those with a prayer page": "Из тех, у кого есть страница молитвы",
"On the map": "На карте",
"Open": "Открыть",
"People groups here": "Народы здесь",
"People nobody is praying for": "Люди, за которых никто не молится",
"People praying": "Молящиеся",
"Place": "Место",
"Placement approximate": "Расположение приблизительное",
"Pools not checked": "Непроверенные источники",
"Pray": "Молиться",
"Primary religion": "Основная религия",
"Province": "Провинция",
"Rank": "Ранг",
"Resource": "Ресурс",
"Scope": "Охват",
"Scripture in this language": "Писание на этом языке",
"Signed film": "Фильм на жестовом языке",
"Signed film link": "Ссылка на фильм на жестовом языке",
"Source": "Источник",
"Speakers": "Носители",
"Their language": "Их язык",
"These peoples on the map": "Эти народы на карте",
"Training in the bridge": "Обучение на языке-посреднике",
"Translated by": "Переводчик",
"Type": "Тип",
"UPG population": "Численность недостигнутых народов",
"Urban %": "Городское население %",
"Ways in": "Пути",
"Ways in (country-level)": "Пути (на уровне страны)",
"Year": "Год"
},
"tableTitles": {
"doxa": "Народы Doxa — что из Евангелия доступно и чего не хватает",
"links": "Ресурсы Евангелия — каждая ссылка и кого она достигает",
"nothing": "Где ничего не найдено",
"countrygap": "Пробел по странам",
"translation": "Приоритет перевода — могут услышать, текст не найден",
"translationcountry": "Приоритет перевода по странам — могут услышать, текст не найден",
"gaps": "Пробелы и возможности",
"bridges": "Возможности городских мостов",
"deaf": "Пробел у глухих",
"resources": "Народы × ресурсы Евангелия",
"languages": "Языки по странам (по рангу) x ресурсы",
"growth": "Доступ к Евангелию и рост населения",
"province": "Доступ к Евангелию по провинциям — Индия и Китай",
"bridgereach": "Один язык — сколько народов: путь, по охвату"
},
"charts": {
"scriptureAny": "Писание (любое)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Мой список UPG 100 — молитвенные списки и плакаты",
"upg100WallTag": "Молитвенная мобилизация",
"upg100WallIntro": "Разделите молитвенный труд на группы по пятьдесят и по сто — так Иисус накормил 5000 (Луки 9:14). Каждый плакат — это список из 100, о котором можно молиться, распечатать и повесить на стену, молясь о них <strong>по имени их народа</strong>.",
"upg100WallIntroA": "Разделите молитвенный труд на группы по пятьдесят и по сто — так Иисус накормил 5000 (Луки 9:14). Каждый плакат — это список из 100, о котором можно молиться, распечатать и повесить на стену, молясь о них ",
"upg100WallIntroEmph": "по имени их народа",
"upg100WallIntroB": ".",
"loadingNamed": "Загрузка {name}…",
"couldNotLoadNamed": "Не удалось загрузить {name}.",
"engineMissing": "Картографический движок {engine} не загрузился.",

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

@ -77,5 +77,6 @@
"people_praying": "祷告的人数",
"selected_first": "所选优先",
"selected_first_hint": "将所选民族置顶,同时保留您的列排序",
"n_shown": "显示 {n} 个"
"n_shown": "显示 {n} 个",
"other": "其他"
}

View file

@ -131,7 +131,8 @@
"t_deaf": "聋人缺口",
"t_resources": "按族群列出资源",
"t_languages": "各国语言",
"t_growth": "可及性与人口增长"
"t_growth": "可及性与人口增长",
"t_bridgereach": "一种语言,覆盖多少民族"
},
"pages": {
"listTitle": "按语言列出的福音资源",
@ -159,7 +160,135 @@
"Bible video": "圣经视频",
"Gospel presentation": "福音简介",
"Countries": "国家",
"People groups": "族群"
"People groups": "族群",
"People Group": "族群",
"Basis": "数据层级",
"Bridge ISO": "桥梁语言 ISO",
"Bridge language": "桥梁语言",
"Film": "影片",
"Groups with nothing": "一无所有的族群",
"People with nothing": "一无所有的人数",
"Pools checked": "已查资源库",
"Audio": "音频",
"Bridge candidate": "候选桥梁语言",
"Bridge film": "桥梁语言影片",
"Bridge scripture": "桥梁语言圣经",
"Bridge strength": "桥梁强度",
"Est. deaths/day": "每日估计死亡人数",
"Film link": "影片链接",
"Full Bible": "全本圣经",
"NO RESOURCE": "无资源",
"Nobody praying": "无人代祷",
"People": "人数",
"Pools NOT checked": "未查的资源库",
"Scripture link": "圣经链接",
"Sign language": "手语",
"Status": "状态",
"Training": "门徒训练",
"% with nothing": "% 一无所有",
"ALIGNED": "条件齐备",
"Audio link": "音频链接",
"Band": "等级",
"Bible stories": "圣经故事",
"Can already hear": "已能听到",
"Candidate % of country": "候选语言占全国 %",
"Candidate ISO": "候选 ISO",
"Candidate is official": "候选为官方语言",
"Carries a resource": "有资源",
"Channel": "渠道",
"Channel source": "渠道来源",
"Code": "代码",
"Code note": "代码说明",
"Code unlookupable": "代码未被任何资源库收录",
"Code unmatchable": "代码无法匹配",
"Deaf bridge": "聋人桥梁",
"Deaf bridge link": "聋人桥梁链接",
"Deaf bridge publisher": "聋人桥梁发布方",
"Deaf programme in country": "国内聋人事工",
"Death rate /1000": "死亡率 /1000",
"Est. deaths/yr": "每年估计死亡人数",
"Example group": "示例族群",
"Film in this language": "该语言影片",
"GAP": "缺口",
"GROWING + UNREACHED": "增长中 + 未接触",
"Gospel audio": "福音音频",
"Groups": "族群数",
"Groups sharing bridge": "共用桥梁的族群",
"Groups speaking": "使用族群",
"Groups speaking it": "使用该语言的族群",
"Groups the map link shows": "地图链接显示的族群",
"Groups with Scripture": "有圣经的族群",
"Groups with film": "有影片的族群",
"Growing & unreached": "增长中且未接触",
"Growth %/yr": "增长率 %/年",
"Has anything": "有任何资源",
"Heart film": "自己语言的影片",
"Heart language": "自己的语言",
"Heart scripture": "自己语言的圣经",
"Internet": "互联网",
"Internet %": "互联网 %",
"Its population": "其人口",
"JESUS Film": "《耶稣传》",
"Kind of gap": "缺口类型",
"Kind of way in": "切入点类型",
"Languages": "语言",
"Languages here": "本地语言",
"Languages they speak": "所用语言",
"Largest group": "最大族群",
"Largest groups": "最大的族群",
"Largest with nothing": "一无所有的最大族群",
"Lead status": "线索状态",
"Link": "链接",
"Method": "方法",
"NT": "新约",
"Note": "备注",
"OPPORTUNITY": "机会",
"Of those with a prayer page": "其中有祷告页面的",
"On the map": "在地图上",
"Open": "打开",
"People groups here": "本地族群",
"People nobody is praying for": "无人代祷的人数",
"People praying": "代祷人数",
"Place": "地点",
"Placement approximate": "位置为近似",
"Pools not checked": "未查资源库",
"Pray": "代祷",
"Primary religion": "主要宗教",
"Province": "省份",
"Rank": "排名",
"Resource": "资源",
"Scope": "涵盖程度",
"Scripture in this language": "该语言圣经",
"Signed film": "手语影片",
"Signed film link": "手语影片链接",
"Source": "来源",
"Speakers": "使用人数",
"Their language": "他们的语言",
"These peoples on the map": "地图上的这些民族",
"Training in the bridge": "桥梁语言培训",
"Translated by": "译者",
"Type": "类型",
"UPG population": "未接触族群人口",
"Urban %": "城镇 %",
"Ways in": "切入点",
"Ways in (country-level)": "切入点(国家层面)",
"Year": "年份"
},
"tableTitles": {
"doxa": "Doxa 族群 — 福音有哪些形式可用,还缺什么",
"links": "福音资源 — 每个链接及其覆盖的人群",
"nothing": "一无所获之处",
"countrygap": "各国缺口",
"translation": "翻译优先级 — 能听,未找到文字",
"translationcountry": "各国翻译优先级 — 能听,未找到文字",
"gaps": "缺口与机会",
"bridges": "城市桥梁机会",
"deaf": "聋人缺口",
"resources": "族群 × 福音资源",
"languages": "各国语言(排名)x 资源",
"growth": "福音可及性与人口增长",
"province": "按省份划分的福音可及性 — 印度和中国",
"bridgereach": "一种语言,覆盖多少民族 — 切入点,按覆盖面排序"
},
"charts": {
"scriptureAny": "圣经(任意)",

View file

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

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "我的 UPG 100 名單 — 禱告名單與海報",
"upg100WallTag": "禱告動員",
"upg100WallIntro": "將禱告的工作分成五十人、一百人一組 — 就像耶穌餵飽五千人那樣(路加福音 9:14)。每一張海報都是一份 100 人名單,你可以為他們禱告、列印出來、貼在牆上 — <strong>按著他們族群的名字</strong>為他們禱告。",
"upg100WallIntroA": "將禱告的工作分成五十人、一百人一組 — 就像耶穌餵飽五千人那樣(路加福音 9:14)。每一張海報都是一份 100 人名單,你可以為他們禱告、列印出來、貼在牆上 — ",
"upg100WallIntroEmph": "按著他們族群的名字",
"upg100WallIntroB": "為他們禱告。",
"loadingNamed": "正在加载{name}…",
"couldNotLoadNamed": "无法加载{name}。",
"engineMissing": "{engine} 地图引擎未能加载。",

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
* internal-tracker/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

@ -0,0 +1,122 @@
#!/usr/bin/env node
// generate-iso3-to-alpha2.mjs — `bun run gen:iso3-to-alpha2`. Offline, no network, no dependency.
//
// Writes library/data/iso3-to-alpha2.json: the alpha-2 code for each of the 242 alpha-3 codes in
// library/data/countries.json, so a country NAME can be localized. Intl.DisplayNames('region') takes
// alpha-2 or UN M49 only -- it does NOT accept alpha-3 -- and our data speaks alpha-3 throughout, so
// without this join every country name is stuck in English.
//
// ★ THIS IS NOT THE RESEARCH MAP. library/scripts/research/out/iso3-to-iso2.json is a different file
// with a different job: the 176-country spine that drives the data FETCHES. Expanding that one changes
// what gets fetched, which is a data-scope decision for the agent (dx-shepherd, REG242B) and must not
// happen as a side effect of adding country names. This map drives no fetch. The two are deliberately
// not interchangeable, which is why they have different names, different shapes and this paragraph.
//
// ★ IT GRADES ITS OWN OUTPUT AND EXITS NON-ZERO. A generator that writes whatever it derived is a
// generator that will one day write a hole. Coverage, injectivity and the single documented exception
// are asserted here, so a bad refresh of the vendored CLDR cannot land quietly.
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
const read = (p) => JSON.parse(readFileSync(join(REPO, p), 'utf8'))
const VENDOR = 'library/vendor/cldr/codeMappings.json'
const OUT = 'library/data/iso3-to-alpha2.json'
// ── the one exception, and why it is not a fudge ─────────────────────────────────────────────────
// Kosovo has NO ISO 3166-1 assignment, so CLDR's codeMappings cannot carry it and no amount of
// re-downloading will add it. XK is the USER-ASSIGNED code the EU, the IMF, Unicode's own CLDR
// locale data (sr_Cyrl_XK) and every major platform settled on. It is recorded here as a named
// exception with its reason rather than patched into the vendored file, so the vendored bytes stay
// exactly upstream's and the deviation stays visible to the next reader.
const EXCEPTIONS = {
KOS: { alpha2: 'XK', why: 'Kosovo has no ISO 3166-1 assignment; XK is the user-assigned code CLDR itself uses in locale ids (sr_Cyrl_XK).' },
}
const cldr = read(VENDOR).supplemental.codeMappings
const countries = read('library/data/countries.json')
// CLDR is keyed by alpha-2 with an _alpha3 inside; we need the inverse.
const byAlpha3 = new Map()
const cldrCollisions = []
for (const [alpha2, v] of Object.entries(cldr)) {
const a3 = v && v._alpha3
if (!a3) continue
if (byAlpha3.has(a3)) cldrCollisions.push(`${a3} claimed by ${byAlpha3.get(a3)} and ${alpha2}`)
else byAlpha3.set(a3, alpha2)
}
const map = {}
const missing = []
for (const { value: iso3, label } of countries) {
const fromCldr = byAlpha3.get(iso3)
const exc = EXCEPTIONS[iso3]
if (fromCldr) map[iso3] = fromCldr
else if (exc) map[iso3] = exc.alpha2
else missing.push(`${iso3} (${label})`)
}
// ── assertions: the generator refuses to ship a map it cannot defend ────────────────────────────
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
ok(countries.length === 242, `the country list holds 242 codes (found ${countries.length})`)
ok(missing.length === 0, missing.length
? `${missing.length} code(s) have NO alpha-2 and no documented exception: ${missing.join(', ')}`
: 'every code resolved, via CLDR or a documented exception')
const seen = new Map()
const dupes = []
for (const [iso3, a2] of Object.entries(map)) {
if (seen.has(a2)) dupes.push(`${a2} claimed by ${seen.get(a2)} and ${iso3}`)
else seen.set(a2, iso3)
}
ok(dupes.length === 0, dupes.length
? `alpha-2 collisions -- at least one country would take another's name: ${dupes.join('; ')}`
: `all ${Object.keys(map).length} codes map to a DISTINCT alpha-2`)
ok(Object.entries(map).every(([, a2]) => /^[A-Z]{2}$/.test(a2)), 'every alpha-2 is two uppercase letters')
// Every exception must still BE one. If CLDR ever assigns KOS, this fires and the entry should go.
for (const [iso3, e] of Object.entries(EXCEPTIONS)) {
ok(!byAlpha3.has(iso3),
`the ${iso3} exception is still needed -- CLDR ${byAlpha3.has(iso3) ? `now assigns it ${byAlpha3.get(iso3)}, so DELETE the exception` : 'still has no assignment for it'}`)
}
// ── cross-check the research map rather than touching it ────────────────────────────────────────
// CLDR is an independent source for the 176 values that map already ships. Disagreements are reported,
// never written: that file drives fetches and is not this generator's to edit.
let disagreements = 0
try {
const research = read('library/scripts/research/out/iso3-to-iso2.json')
for (const [iso3, a2] of Object.entries(research)) {
const c = byAlpha3.get(iso3)
if (c && c !== a2) { console.log(` FAIL - research map says ${iso3} -> ${a2}, CLDR says ${c}`); disagreements++ }
}
ok(disagreements === 0,
`CLDR agrees with all ${Object.keys(research).length} values in the research spine map (reported, never rewritten -- that file drives fetches)`)
} catch (e) {
console.log(` note - research map not readable here (${e.code || e.message}); cross-check skipped`)
}
if (fails) {
console.log(`\nISO3-TO-ALPHA2: ${fails} FAIL(s) — nothing written`)
process.exit(1)
}
const cldrVersion = '48.2.2' // keep in step with library/vendor/cldr/README.md
writeFileSync(join(REPO, OUT), JSON.stringify({
_meta: {
generated_by: 'library/scripts/generate-iso3-to-alpha2.mjs (bun run gen:iso3-to-alpha2)',
purpose: 'alpha-3 -> alpha-2 so Intl.DisplayNames can produce a LOCALIZED country name. Drives no data fetch.',
not_to_be_confused_with: 'library/scripts/research/out/iso3-to-iso2.json — the 176-country research spine that DOES drive fetches',
source: `CLDR cldr-core ${cldrVersion}, vendored at ${VENDOR} (Unicode-3.0)`,
countries: Object.keys(map).length,
exceptions: Object.fromEntries(Object.entries(EXCEPTIONS).map(([k, v]) => [k, `${v.alpha2} — ${v.why}`])),
},
map,
}, null, 2) + '\n')
console.log(`\nISO3-TO-ALPHA2: wrote ${OUT} — ${Object.keys(map).length} countries, ${Object.keys(EXCEPTIONS).length} documented exception(s)`)

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

@ -118,6 +118,7 @@
"NFK": "NF",
"NGA": "NG",
"NIU": "NU",
"NOR": "NO",
"NPL": "NP",
"NZL": "NZ",
"OMN": "OM",
@ -137,7 +138,7 @@
"SDN": "SD",
"SEN": "SN",
"SHN": "SH",
"SJM": "NO",
"SJM": "SJ",
"SLB": "SB",
"SLE": "SL",
"SMR": "SM",

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

@ -62,7 +62,17 @@ else ok('every value is a well-formed 2-letter uppercase code');
// 3. SPOT-CHECK the codes this corpus actually got wrong, so a regression is named
// rather than merely counted. These are the sovereign states whose alpha-2 was
// previously their neighbour's or their former administrator's.
const MUST = { NER: 'NE', NGA: 'NG', PSE: 'PS', ISR: 'IL', HKG: 'HK', MAC: 'MO', ABW: 'AW' };
// SJM belongs here for the same reason PSE and HKG do: it was mapped to its administrator's code.
// SJM -> NO served NORWAY's data under the name "Svalbard and Jan Mayen", and INJECTIVITY COULD NOT
// SEE IT -- the check passes as long as no two codes share an ISO2, and NOR is absent from this map
// entirely, so "NO" had exactly one claimant and looked clean. A uniqueness test cannot tell a right
// code from a wrong one that nobody else wanted; only a named expectation can.
// NOR belongs here as the OTHER HALF of the SJM defect. Norway was absent from this map entirely,
// which is why SJM -> NO looked injective and why Norway appeared in no downstream table at all --
// consumers filter on `ISO3_2[iso3]`, so a missing code is not an error, it is a silent omission.
// Added from ISO 3166-1 (NO/NOR), which is what CLDR's codeMappings encodes; the full CLDR vendor
// still comes with BUILD-ISO3-TABLE-COVERS-ALL-242-COUNTRIES and will re-derive this alongside 66 others.
const MUST = { NER: 'NE', NGA: 'NG', PSE: 'PS', ISR: 'IL', HKG: 'HK', MAC: 'MO', ABW: 'AW', SJM: 'SJ', NOR: 'NO' };
// ABSENT MUST FAIL, NOT PASS. The first version read `map[k] && map[k] !== v`, so a
// code MISSING ENTIRELY was falsey and slipped through — deleting NER outright passed
// all three checks and this line still printed "NER … all correct" about a code that
@ -77,7 +87,9 @@ if (!missing.length && !wrong.length) ok('previously-wrong codes (NER, PSE, HKG,
// The map must also stay COMPLETE. A silent shrink (175 -> 174) means a country
// stopped being fetched at all, which no per-code check can see.
const EXPECTED_MIN = 175;
// 175 -> 176 with NOR. The floor RISES when a code is added, or the check stops being able to see the
// shrink it exists for: left at 175 it would have called a regression back to the pre-NOR map clean.
const EXPECTED_MIN = 176;
if (Object.keys(map).length < EXPECTED_MIN) {
fail(`map has ${Object.keys(map).length} codes, expected at least ${EXPECTED_MIN} — a country has DISAPPEARED`);
} else ok(`${Object.keys(map).length} codes present (>= ${EXPECTED_MIN})`);

View file

@ -0,0 +1,100 @@
/**
* countryName.js — a country's name in the reader's language.
*
* Our data speaks ISO 3166-1 ALPHA-3 ("DEU"). Intl.DisplayNames('region') accepts alpha-2 or UN M49
* and NOT alpha-3, so every country name was stuck in English until the two were joined. The join is
* library/data/iso3-to-alpha2.json, derived from vendored CLDR by generate-iso3-to-alpha2.mjs.
*
* ★ Intl.DisplayNames THROWS on a missing code. `dn.of(undefined)` is a RangeError, not a null return,
* so every path here checks the alpha-2 before asking. That is the single defect this file exists to
* not have: a country absent from the map would otherwise take down whatever rendered it.
*
* ★ AND IT THROWS ON A MALFORMED ONE TOO, which is why the regex guard is not redundant with the
* lookup. dn.of('XYZ') is also a RangeError -- 'structurally invalid' is a different failure from
* 'not found', and only the second returns the input unchanged.
*
* FALLBACK ORDER, each step a real answer rather than a placeholder:
* 1. the localized name from Intl.DisplayNames
* 2. the canonical ENGLISH name from library/data/countries.json — a real name in the wrong language
* beats a bare code for a reader, and it is what our own tables already show
* 3. the alpha-3 code itself, so something identifiable always renders
*
* No i18n framework is required: this reads a locale STRING the caller already has.
*
* ★★ WHERE NOT TO USE THIS, MEASURED. It has NO consumer yet on purpose. Intl's English names
* differ from the canonical names in library/data/countries.json for 42 OF 242 COUNTRIES -- "Antigua &
* Barbuda" vs "Antigua and Barbuda", "Cape Verde" vs "Cabo Verde", "Hong Kong SAR China" vs "Hong
* Kong", "Myanmar (Burma)" vs "Myanmar", "Caribbean Netherlands" vs "Bonaire, Sint Eustatius, and
* Saba". So this is NOT a drop-in replacement for a name we already display:
*
* ✗ do NOT swap it in where an API already returns a LOCALIZED label. region-dashboards.vue:370
* prefers the API's label deliberately, and the regions surfaces are measured against live
* doxa.life -- switching the source would change 42 English names and break clone parity, which
* is a product decision about which name is canonical, not a refactor.
* ✓ DO use it where the only thing in hand is an alpha-3 and there is no label at all, or where
* a label arrives in English regardless of the reader's locale. That is the gap it was built for.
*
* Stated here rather than left to be discovered, because the failure mode is silent: the names all
* still look like names.
*/
import table from '../data/iso3-to-alpha2.json'
import countries from '../data/countries.json'
const ALPHA2 = table.map
const ENGLISH = new Map(countries.map((c) => [c.value, c.label]))
/** The alpha-2 for an alpha-3 code, or undefined. Exported because callers building URLs and flag
* assets need the same lookup, and a second copy of it would drift. */
export function alpha2Of(iso3) {
return iso3 ? ALPHA2[String(iso3).toUpperCase()] : undefined
}
/** The canonical English name, or undefined. */
export function englishName(iso3) {
return iso3 ? ENGLISH.get(String(iso3).toUpperCase()) : undefined
}
// One DisplayNames per locale. Constructing it is not cheap and a country list rebuilds it per row;
// a Map keyed by locale is the whole optimisation. A locale that throws is cached as null so the
// failure costs one construction, not one per call.
const cache = new Map()
function displayNames(locale) {
const key = locale || 'en'
if (cache.has(key)) return cache.get(key)
let dn = null
try {
dn = new Intl.DisplayNames([key], { type: 'region' })
} catch (_) {
dn = null // an unsupported locale tag, or no Intl.DisplayNames at all
}
cache.set(key, dn)
return dn
}
/**
* @param {string} iso3 ISO 3166-1 alpha-3, e.g. 'DEU'. Case-insensitive.
* @param {string} [locale] BCP-47 tag, e.g. 'ar'. Defaults to English.
* @returns {string} the country's name in that locale, its English name, or the code.
*/
export function countryName(iso3, locale) {
const code = iso3 ? String(iso3).toUpperCase() : ''
const a2 = ALPHA2[code]
const fallback = ENGLISH.get(code) || code
// Guard BEFORE asking: dn.of(undefined) and dn.of('XYZ') both throw.
if (!a2 || !/^[A-Z]{2}$/.test(a2)) return fallback
const dn = displayNames(locale)
if (!dn) return fallback
try {
const name = dn.of(a2)
// An UNKNOWN-but-well-formed region comes back as the input string. That is not a name, so it
// must not win over our English label -- this is the difference between "no translation exists"
// and "the translation is literally 'DE'".
return name && name !== a2 ? name : fallback
} catch (_) {
return fallback
}
}
export default countryName

41
library/vendor/cldr/LICENSE vendored Normal file
View file

@ -0,0 +1,41 @@
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 2015-2024 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
SPDX-License-Identifier: Unicode-3.0

42
library/vendor/cldr/README.md vendored Normal file
View file

@ -0,0 +1,42 @@
# Vendored CLDR — `codeMappings.json`
The ISO 3166-1 alpha-2 / alpha-3 / numeric correspondences, used to turn the alpha-3 codes our data
speaks into the alpha-2 codes `Intl.DisplayNames` requires to produce a LOCALIZED country name.
| | |
|---|---|
| Package | `cldr-core` (unicode-org/cldr-json) |
| Version | **48.2.2** |
| Source | `https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-core/supplemental/codeMappings.json` |
| Licence | **Unicode-3.0** — full text in `LICENSE` beside this file |
| Bytes | 35,948 |
| sha256 | `0d1ef50b92c1140e5847d22d96faf1a9c35543b0dedf8e9a2fcb87e4c51b9ed6` |
| Vendored | 2026-09-27 |
## Why vendored rather than a dependency
No npm package is added for this. A dependency would pull CLDR's whole supplemental tree, and it would
make the derivation of a shipped data file depend on an install step. The file is 35KB, it is
licensed for redistribution, and vendoring it means `bun run gen:iso3-to-alpha2` reproduces the derived
map OFFLINE — which is the actual point of vendoring, not merely having a copy.
## What is derived from it
`library/scripts/generate-iso3-to-alpha2.mjs` joins this file against the 242 alpha-3 codes in
`library/data/countries.json` and writes `library/data/iso3-to-alpha2.json`. The generator asserts its
own output (242 coverage, injectivity, the one documented exception) and exits non-zero if any of it
fails, so a bad refresh cannot land quietly.
## DO NOT CONFUSE THIS WITH THE RESEARCH MAP
`library/scripts/research/out/iso3-to-iso2.json` is a DIFFERENT map with a different job: it is the
176-country research spine that drives the data fetches, and expanding it changes what gets fetched —
a data-scope decision that belongs to the agent, not a side effect of adding country names. The map
derived here is for DISPLAY NAMES ONLY and drives no fetch. CLDR agrees with all 176 values in the
research map, which is a useful independent check on it, but the two files are not interchangeable.
## Refreshing
Re-download the URL above, update the version, bytes and sha256 in this table, then run the generator
and let its assertions grade the result. If CLDR ever disagrees with a value in the research map, the
generator says so rather than silently overwriting anything.

1934
library/vendor/cldr/codeMappings.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -161,6 +161,7 @@
"check:one-palette": "node internal/check-one-palette.mjs",
"check:one-palette:self-test": "node internal/check-one-palette.mjs --self-test",
"check:optional-t-equivalence": "node internal/check-optional-t-equivalence.mjs",
"check:package-json-unique-keys": "node internal/check-package-json-unique-keys.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check",
"check:packet-render": "node internal/check-packet-render.mjs",
@ -194,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:shadow-css-parses": "node internal/check-shadow-css-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",
@ -232,8 +236,9 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope",
"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 check:shadow-css-parses",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"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",
@ -336,7 +341,7 @@
"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 the agent); 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-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. ROUND 2, after go-int planted T('poison_key') behind `const T = useGiT()` and this gate reported the tree CLEAN -- the one failure a check is not forgiven, because a red gate gets fixed and a green one that should be red gets believed. THREE independent blindnesses stacked, and any one alone hid the key: the file never writes the word tStatic so the precondition skipped it before anything ran; useGiT is not tStatic so no alias was found; and the alias is PREFIXED (useOptionalT carries a namespace) while its call sites pass BARE keys, which the full-key pattern cannot match because it requires a dot. Now a translator that arrives from a COMPOSABLE is resolved like any other, with the namespace READ from the factory's one-line body rather than listed here -- useOptionalT/useOptionalI18n directly, plus any `export function useXxxT() { return useOptionalT('ns') }`, which is how useNeighborT and usePplrT are written. Because qualify() prefixes a bare key and leaves an already-qualified one alone, one binding registers as BOTH shapes. MEASURED before and after with the same poison on disk: the old version PASSES it, this one fails it as 'neighbor.zzz_not_a_real_key (via nt())'; coverage went 683 -> 743 full-key and 49 -> 56 prefixed call sites, so 67 sites were being scored clean without being read. A self-test now asserts all three fixes separately and exits 3 if any goes blind, because this gate guards goal (d) and had already been trusted once while lying. Empty-namespace bare keys stay outside the model and are COUNTED rather than assumed away; measured at zero today, since all five such sites pass dotted keys.",
"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 agent'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.",
@ -375,6 +380,7 @@
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",
"check:optional-t-equivalence": "C4's shared guarded translator (library/composables/useOptionalT.js) must behave like the twenty hand-written try{useI18n()}catch blocks it replaces: all three host states, and the per-site register asserts each migrated file still passes ITS OWN fallback rather than inheriting the catalogue, because routing them through tStatic would change what seven bare-host bundles render. EVERY LATER C4 WAVE APPENDS A ROW. Extract-and-executes the module source, since useOptionalT imports vue-i18n and an i18n index that calls import.meta.glob.",
"check:package-json-unique-keys": "no object in package.json may carry the same key twice: JSON.parse keeps the last silently, so a duplicate kills the first entry while every gate that reads the PARSED object passes (a //scripts note sat twice after a merge). A small scanner tracks keys per object; its self-test runs every time.",
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
"check:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
"check:packet-render:expect-funnel": "check:packet-render with the page-2 FUNNEL declared REQUIRED. Exists because of a real near-miss: a merge resolved with `git checkout --ours` deleted the printPacket call site that supplies the funnel data (60 lines) and EVERY GATE STAYED GREEN — it had to, because the funnel page is OPTIONAL by design (buildPacket omits it when the arg is null), so a packet without page 2 is not malformed, just smaller. Always requiring it would be the wrong fix: the 11 pre-funnel laos packets and any host with no funnel data would go red, and a false red costs as much as a false green. So the CALLER, which knows whether this packet was built with funnel data, declares it — an absence is then either declared (no flag) or a failure (flag passed, page missing), never a silent nothing. Pass it for any packet from a profile that wires packetFunnel(). Browser gate.",
@ -395,7 +401,9 @@
"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 agent'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:shadow-css-parses": "the CSS we assemble as JS STRINGS must parse as CSS — brace balance and declarations stranded outside any rule. check:sfc-parse proves only that the JS parses, so it passed a stylesheet with 9 orphaned declarations and braces at 12 open / 21 close (REGLIB batch 1); nothing else in the tree validates CSS syntax. Reads every useShadowStyles() argument off the @babel/parser AST the build already uses, because three hand-rolled lexers each produced a confident FALSE RED: a /* CSS comment */ read as a declaration, an apostrophe in a .vue prose header opening a phantom string literal, and a regex literal containing a quote. 9 self-tests run inline on every invocation, including the exact batch-1 damage, so it cannot silently stop being able to fail.",
"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.",
"check:shot-fidelity": "warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box — a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; the `check:shot-fidelity:strict` script to block once an emoji font is installed.",
@ -419,6 +427,7 @@
"check:zone-census": "every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
"gen:iso3-to-alpha2": "derive library/data/iso3-to-alpha2.json from vendored CLDR (48.2.2, Unicode-3.0) joined against the 242 codes in library/data/countries.json, so Intl.DisplayNames can produce a LOCALIZED country name — it takes alpha-2 and our data speaks alpha-3. Offline, no dependency. GRADES ITS OWN OUTPUT and writes nothing on failure: 242 coverage, injectivity, and KOS -> XK as the one documented exception (Kosovo has no ISO assignment). Cross-checks the 176-country research spine map and REPORTS disagreements without rewriting it — that file drives fetches and is not this generator's to edit.",
"internet:adm1": "regenerate data/tables/internet-by-adm1.json (internet use by first-level area; India from NFHS-5 2019-21, CC BY 4.0) from data/internet/ through a strict name crosswalk to the shipped gb-IND-ADM1 boundaries. Fails on an unmatched name, never guesses. Generic rows (iso3, adm1, measure, sex, area, value, source, year) so other plugins and go-int can reuse it.",
"library-index": "regenerate documentation/reference-library-index.md — the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
"lists": "regenerate data/people-groups/lists/<id>.json, the DOXA slugs in each people-group list of library/lists/lists.json, from the IMB CSV and the DOXA snapshot. Re-run after either changes.",

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 || []) {

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