Translate the chrome that was English in all eleven locales

Measured under ?lang=ru on both composites: every Latin-script string left on the
page was ours. The map above was fully translated; underneath it the grid, the
map's own side menu and filter panel, and the whole research dashboard were
English in Arabic, Chinese, Hindi, Russian and everything else.

Three surfaces, three reasons, none of them a missing translation:

  the grid          read labels from a host-supplied `t` object that nothing ever
                    filled, with an English literal at each call site as fallback
  side menu +
  filter panel      plain string literals
  dashboard         labels declared as literals in dashboard.config.js — facet
                    rail, chart titles, KPI strip, table headers, segmented
                    control, search placeholder

New catalogues for all 11 locales (grid, controls, dashboard), REUSING the
catalogue's existing wording wherever the word already existed — Регион, Блок
родства, Население, Вовлечён, Графики — so two panels never name one thing two
ways, and nothing is coined that the project already has a word for.

Read through a new plugin-free tStatic() rather than useI18n, because these
components are also mounted by entries that install no i18n plugin. An unguarded
useI18n() throws and a thrown setup() removes the subtree with no build error —
the defect that had just blanked people-groups-grid everywhere (6603dc17).

★ THE CONFIG LABELS ARE GETTERS, AND A SPREAD WOULD HAVE LOOKED IDENTICAL.
dashboard.config.js is imported once at load; the locale is only known later. My
first version spread a getter object, and a spread COPIES THE RESOLVED VALUE, not
the accessor — every label would have frozen at whichever locale loaded first,
and it would have looked perfect in English. `get label() {…}` resolves on each
read.

MEASURED after, served built tree, identity checked first: Big Picture ru/ar and
Simple Research ru/zh have ZERO Latin-script chrome strings. What remains is
Mapbox's licence attribution (never translate), country and people-group names
(data), and 'UUPGs' — which every locale file deliberately keeps as the acronym.

Three gates went red on the way, two of them mine and worth recording:
  check:locale-reachable  slices this i18n module between two markers and EVALS
                          the slice; I had put tStatic between them, so the gate
                          hit an `export`. Moved to the end of the file.
  check:library-index     my import+helper above a component's docblock became
                          that component's DERIVED description. Moved below it.
  check:kpi-unit          its parser assumed single-brace entries, so getters made
                          it find ZERO pct KPIs — and it failed rather than
                          printing a green PASS about nothing, which is the gate
                          working. Parse now understands getters AND checks the
                          translated labels, so a '%' reintroduced in any of the
                          11 catalogues fails by name. Both controls fired.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 06:10:27 +00:00
parent 14f8388172
commit b4a0387495
40 changed files with 1095 additions and 93 deletions

View file

@ -25,7 +25,7 @@
*
* NEGATIVE-TESTED: restoring either label ('% unreached' or 'Adopted %') fails by name with exit 1.
*/
import { readFileSync, existsSync } from 'node:fs'
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -41,10 +41,22 @@ const src = readFileSync(CFG, 'utf8')
// Strip comments before scanning. This file now DOCUMENTS the defect in a comment right above the
// fixed line, and a naive scan matches that prose and fails on the explanation of the fix — the
// same self-reference that has bitten three gates in this repo already.
// The translated labels live in the catalogue, so the rule has to reach them too.
const LOC_DIR = resolve(ROOT, 'library/i18n/locales')
const LOCALES = existsSync(LOC_DIR) ? readdirSync(LOC_DIR) : []
const catalogue = (loc) => {
try { return JSON.parse(readFileSync(resolve(LOC_DIR, loc, 'dashboard.json'), 'utf8')) } catch { return {} }
}
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
// Each KPI entry is a single-line-ish object literal: { id: '…', label: '…', kind: '…', … }
const entries = [...code.matchAll(/\{[^{}]*\bid:\s*'([^']+)'[^{}]*\}/g)]
// Each KPI entry is an object literal: { id: '…', label: '…', kind: '…', … }.
// ★ IT MAY NOW CONTAIN ONE LEVEL OF NESTED BRACES. The labels became GETTERS when the dashboard was
// translated (`get label() { return tStatic('dashboard.unreached') || 'unreached' }`), and the old
// single-brace pattern stopped matching every entry — the gate found ZERO pct KPIs and failed
// exactly as it was built to, rather than printing a green PASS about nothing. That refusal is the
// gate working; this is the parse catching up with the config, not a relaxation of the rule.
const entries = [...code.matchAll(/\{(?:[^{}]|\{[^{}]*\})*\bid:\s*'([^']+)'(?:[^{}]|\{[^{}]*\})*\}/g)]
const offenders = []
let pctSeen = 0
@ -53,8 +65,20 @@ for (const m of entries) {
const kind = (body.match(/\bkind:\s*'([^']+)'/) || [])[1]
if (kind !== 'pct') continue
pctSeen++
const label = (body.match(/\blabel:\s*'([^']*)'/) || [])[1]
// Two label forms: a plain literal, or a getter that falls back to one.
const plain = (body.match(/\blabel:\s*'([^']*)'/) || [])[1]
const viaGetter = body.match(/get label\(\)\s*\{[^}]*?tStatic\('([^']+)'\)\s*\|\|\s*'([^']*)'/)
const label = plain ?? (viaGetter ? viaGetter[2] : undefined)
if (label && label.includes('%')) offenders.push({ id: m[1], label })
// ★ AND CHECK WHAT A NON-ENGLISH READER SEES. The rule is about the rendered label carrying a
// second percent sign, and a translation can reintroduce one where the English fallback has none.
if (viaGetter) {
const key = viaGetter[1].replace(/^dashboard\./, '')
for (const loc of LOCALES) {
const v = catalogue(loc)[key]
if (typeof v === 'string' && v.includes('%')) offenders.push({ id: m[1] + ' [' + loc + ']', label: v })
}
}
}
if (!pctSeen) {

View file

@ -20,6 +20,9 @@
* useMapData when normalizing). If facets aren't ready yet, we render a
* placeholder so the panel never blocks initial paint.
*/
import { tStatic } from '@map/i18n/index.js'
// See ResearchMapSideMenu for why this reads the catalogue directly rather than through useI18n.
const L = (key, en) => tStatic('controls.' + key) || en
import { inject, computed, watch, ref } from 'vue'
import { useShadowStyles } from '../composables/useShadowStyles.js'
@ -141,37 +144,37 @@ useShadowStyles(`
<div class="rm-filter">
<!-- Population range ──────────────────────────────────────────────── -->
<div class="field">
<label>Population</label>
<label>{{ L('population', 'Population') }}</label>
<div class="rm-pop-row">
<input type="number" min="0" placeholder="min" :value="popMin" @input="popMin = $event.target.value" />
<input type="number" min="0" placeholder="max" :value="popMax" @input="popMax = $event.target.value" />
<input type="number" min="0" :placeholder="L('min', 'min')" :value="popMin" @input="popMin = $event.target.value" />
<input type="number" min="0" :placeholder="L('max', 'max')" :value="popMax" @input="popMax = $event.target.value" />
</div>
</div>
<!-- Engagement / adoption status ──────────────────────────────────── -->
<div class="field">
<label>Engagement</label>
<label>{{ L('engagement', 'Engagement') }}</label>
<select :value="engagement" @change="engagement = $event.target.value">
<option value="any">Any</option>
<option value="engaged">Engaged</option>
<option value="unengaged">Unengaged</option>
<option value="engaged">{{ L('engaged', 'Engaged') }}</option>
<option value="unengaged">{{ L('unengaged', 'Unengaged') }}</option>
</select>
</div>
<div class="field">
<label>Adoption</label>
<label>{{ L('adoption', 'Adoption') }}</label>
<select :value="adoption" @change="adoption = $event.target.value">
<option value="any">Any</option>
<option value="adopted">Adopted</option>
<option value="unadopted">Unadopted</option>
<option value="adopted">{{ L('adopted', 'Adopted') }}</option>
<option value="unadopted">{{ L('unadopted', 'Unadopted') }}</option>
</select>
</div>
<!-- Language multi-select ─────────────────────────────────────────── -->
<div class="field">
<label>Language</label>
<input type="search" v-model="langSearch" placeholder="Search languages…" />
<label>{{ L('language', 'Language') }}</label>
<input type="search" v-model="langSearch" :placeholder="L('search_languages', 'Search languages…')" />
<div class="rm-chips">
<span v-if="!filteredLangs.length" class="rm-empty">No languages loaded yet</span>
<span v-if="!filteredLangs.length" class="rm-empty">{{ L('no_languages', 'No languages loaded yet') }}</span>
<button
v-for="lang in filteredLangs"
:key="lang"
@ -184,10 +187,10 @@ useShadowStyles(`
<!-- Religion multi-select ─────────────────────────────────────────── -->
<div class="field">
<label>Religion</label>
<input type="search" v-model="relSearch" placeholder="Search religions…" />
<label>{{ L('religion', 'Religion') }}</label>
<input type="search" v-model="relSearch" :placeholder="L('search_religions', 'Search religions…')" />
<div class="rm-chips">
<span v-if="!filteredRels.length" class="rm-empty">No religions loaded yet</span>
<span v-if="!filteredRels.length" class="rm-empty">{{ L('no_religions', 'No religions loaded yet') }}</span>
<button
v-for="rel in filteredRels"
:key="rel"
@ -201,7 +204,7 @@ useShadowStyles(`
<!-- Footer: active count + reset link ─────────────────────────────── -->
<div class="rm-filter-footer">
<span class="rm-pill">{{ activeCount }} active</span>
<button class="rm-link-btn" @click="resetAll">Reset all</button>
<button class="rm-link-btn" @click="resetAll">{{ L('reset_all', 'Reset all') }}</button>
</div>
</div>
</template>

View file

@ -32,15 +32,24 @@ defineProps({
})
const emit = defineEmits(['close', 'open-poster', 'reload-map'])
import { tStatic } from '@map/i18n/index.js'
// ★ THIS PANEL WAS ENGLISH IN ALL ELEVEN LOCALES. Measured on the composite under ?lang=ru: every
// Latin-script string left on the page belonged to this menu and its filter sibling — the map
// around them was fully translated. Read through tStatic rather than useI18n: these components are
// reachable from mounts that install no i18n plugin, and an unguarded useI18n() throws and takes
// the subtree with it (the defect that blanked people-groups-grid, fixed in 6603dc17). English
// stays as the last argument, so a missing key degrades to today's text rather than to a key name.
const L = (key, en) => tStatic('controls.' + key) || en
const mapStore = inject('mapStore')
const uiStore = inject('uiStore')
// ─── Cluster controls ────────────────────────────────────────────────────────
const CLUSTER_MODES = [
{ id: 'off', label: 'Off', desc: 'Show every pin individually' },
{ id: 'mapbox', label: 'Native', desc: 'Mapbox circle clusters (fastest)' },
{ id: 'mst', label: 'MST', desc: 'Minimum-spanning-tree affinity webs' },
{ id: 'network', label: 'Network', desc: 'Nearest-neighbor connection lines' }
{ id: 'off', label: L('off', 'Off'), desc: L('off_desc', 'Show every pin individually') },
{ id: 'mapbox', label: L('native', 'Native'), desc: L('native_desc', 'Mapbox circle clusters (fastest)') },
{ id: 'mst', label: L('mst', 'MST'), desc: L('mst_desc', 'Minimum-spanning-tree affinity webs') },
{ id: 'network', label: L('network', 'Network'), desc: L('network_desc', 'Nearest-neighbor connection lines') }
]
const clusterMode = computed({
get: () => mapStore?.clusteringMode ?? 'off',
@ -87,14 +96,14 @@ useShadowStyles(`
<div class="rm-side-backdrop" :class="{ open: isOpen }" @click="emit('close')" />
<aside class="rm-side-drawer" :class="{ open: isOpen }" role="dialog" aria-label="Research map controls">
<header class="rm-side-header">
<h3 class="rm-side-title">Research controls</h3>
<h3 class="rm-side-title">{{ L('research_controls', 'Research controls') }}</h3>
<button class="rm-side-close" aria-label="Close" @click="emit('close')">×</button>
</header>
<div class="rm-side-body">
<!-- Clustering ─────────────────────────────────────────────────── -->
<div class="rm-section">
<h4>Clustering</h4>
<h4>{{ L('clustering', 'Clustering') }}</h4>
<div class="rm-cluster-grid">
<button
v-for="m in CLUSTER_MODES"
@ -108,7 +117,7 @@ useShadowStyles(`
</button>
</div>
<div v-if="clusterMode !== 'off'" class="rm-slider-row">
<label for="rm-pin-op">Pin dim</label>
<label for="rm-pin-op">{{ L('pin_dim', 'Pin dim') }}</label>
<input
id="rm-pin-op"
type="range"
@ -124,14 +133,14 @@ useShadowStyles(`
<!-- Filter panel slot ──────────────────────────────────────────── -->
<div class="rm-section">
<h4>Filters</h4>
<h4>{{ L('filters', 'Filters') }}</h4>
<slot />
</div>
</div>
<footer class="rm-side-footer">
<button class="rm-btn" @click="emit('open-poster')">Export poster…</button>
<button class="rm-btn ghost" @click="emit('reload-map')">Reload data</button>
<button class="rm-btn" @click="emit('open-poster')">{{ L('export_poster', 'Export poster…') }}</button>
<button class="rm-btn ghost" @click="emit('reload-map')">{{ L('reload_data', 'Reload data') }}</button>
</footer>
</aside>
</template>

View file

@ -93,34 +93,6 @@ export function detectLocale() {
/**
* Build a fresh i18n instance. Called once per <doxa-map> element.
*/
/**
* Read a catalogue string WITHOUT the vue-i18n plugin.
*
* WHY THIS EXISTS. Some components are mounted as their OWN custom element by a bundle entry that
* calls defineCustomElement(Component) and installs nothing — people-groups-grid is one. useI18n()
* inside such a component THROWS, and a thrown setup() removes the subtree silently (this repo has
* already paid for that once: three buttons vanished behind ignorable console noise). So a shared
* component that must work in BOTH mounts needs a plugin-free reader, which is all this is.
*
* Falls back locale -> en -> undefined, so a caller can keep its own literal as the last resort and
* a missing translation degrades to English rather than to a key.
*
* @param {string} path dotted, e.g. 'grid.hide_filters'
* @param {string} [locale] defaults to the detected one (?lang= / host / browser)
*/
export function tStatic(path, locale) {
const tryTree = (loc) => {
let node = messages[loc]
for (const part of String(path).split('.')) {
if (!node || typeof node !== 'object') return undefined
node = node[part]
}
return typeof node === 'string' ? node : undefined
}
const want = locale || detectLocale()
return tryTree(want) ?? tryTree('en')
}
export function createAppI18n() {
const locale = detectLocale()
// ⚠ REFLECT AN RTL LOCALE ON THE DOCUMENT. Only one component reads RTL_LOCALES
@ -149,3 +121,31 @@ export function createAppI18n() {
}
export default createAppI18n
/**
* Read a catalogue string WITHOUT the vue-i18n plugin.
*
* WHY THIS EXISTS. Some components are mounted as their OWN custom element by a bundle entry that
* calls defineCustomElement(Component) and installs nothing — people-groups-grid is one. useI18n()
* inside such a component THROWS, and a thrown setup() removes the subtree silently (this repo has
* already paid for that once: three buttons vanished behind ignorable console noise). So a shared
* component that must work in BOTH mounts needs a plugin-free reader, which is all this is.
*
* Falls back locale -> en -> undefined, so a caller can keep its own literal as the last resort and
* a missing translation degrades to English rather than to a key.
*
* @param {string} path dotted, e.g. 'grid.hide_filters'
* @param {string} [locale] defaults to the detected one (?lang= / host / browser)
*/
export function tStatic(path, locale) {
const tryTree = (loc) => {
let node = messages[loc]
for (const part of String(path).split('.')) {
if (!node || typeof node !== 'object') return undefined
node = node[part]
}
return typeof node === 'string' ? node : undefined
}
const want = locale || detectLocale()
return tryTree(want) ?? tryTree('en')
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "أدوات البحث",
"clustering": "التجميع",
"filters": "عوامل التصفية",
"pin_dim": "خفوت النقاط",
"export_poster": "تصدير ملصق…",
"reload_data": "إعادة تحميل البيانات",
"off": "إيقاف",
"native": "أصلي",
"mst": "الشجرة الممتدة",
"network": "شبكة",
"off_desc": "إظهار كل نقطة على حدة",
"native_desc": "تجميع دوائر Mapbox (الأسرع)",
"mst_desc": "شبكات تقارب بالشجرة الممتدة الصغرى",
"network_desc": "خطوط ربط بأقرب جار",
"search_languages": "ابحث عن اللغات…",
"search_religions": "ابحث عن الأديان…",
"reset_all": "إعادة تعيين الكل",
"no_languages": "لم تُحمَّل أي لغات بعد",
"no_religions": "لم تُحمَّل أي أديان بعد",
"unadopted": "غير متبنّى",
"min": "الأدنى",
"max": "الأعلى",
"adopted": "متبنَّاة",
"adoption": "التبني",
"engaged": "مُخْدُومَة",
"engagement": "المشاركة",
"unengaged": "يحتاج إلى انخراط",
"language": "اللغة",
"religion": "الدين",
"population": "السكان"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "كتلة التقارب",
"gospel_access": "الوصول إلى الإنجيل",
"people": "الشعوب",
"people_name": "الشعب (الاسم)",
"praying": "يصلّون",
"religion_family": "عائلة الأديان",
"search_language": "ابحث عن لغة",
"search_people": "ابحث عن شعب",
"search_place": "ابحث عن مكان",
"search_religion": "ابحث عن دين",
"upgs_in_scope": "الشعوب ضمن النطاق",
"countries_touched": "الدول المشمولة",
"unreached": "غير موصولين",
"sum_population": "مجموع السكان",
"sum_praying": "مجموع المصلّين",
"charts": "الرسوم البيانية",
"table": "الجدول",
"cluster": "المجموعة",
"country": "الدولة",
"engagement": "المشاركة",
"language": "اللغة",
"language_family": "عائلة اللغة",
"people_group": "الشعب",
"population": "السكان",
"prayer": "الصلاة",
"region": "المنطقة",
"religion": "الدين",
"status": "الحالة",
"adopted": "متبنَّاة",
"filters": "عوامل التصفية",
"gospel_resources": "موارد الإنجيل",
"map": "الخريطة",
"search_placeholder": "ابحث عن الشعوب والأماكن واللغات والأديان…"
}

View file

@ -0,0 +1,19 @@
{
"search": "ابحث عن الشعوب…",
"hide_filters": "إخفاء عوامل التصفية",
"show_filters": "إظهار عوامل التصفية",
"total": "الإجمالي",
"showing": "المعروض",
"load_more": "عرض المزيد",
"full_profile": "الملف الكامل",
"wagf_region": "المنطقة",
"wagf_block": "كتلة التقارب",
"rop1": "الشعب",
"religion": "الدين",
"exact_filter": "تطابق تام",
"clear_all": "مسح الكل",
"loading": "جارٍ التحميل…",
"no_results": "لا توجد شعوب مطابقة لهذا التصفية.",
"type_to_search": "اكتب للبحث...",
"load_failed": "تعذّر تحميل بيانات الشعوب. هذه مشكلة اتصال، وليست مشكلة في التصفية."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Forschungssteuerung",
"clustering": "Clustering",
"filters": "Filter",
"pin_dim": "Pin-Transparenz",
"export_poster": "Poster exportieren…",
"reload_data": "Daten neu laden",
"off": "Aus",
"native": "Nativ",
"mst": "MST",
"network": "Netzwerk",
"off_desc": "Jeden Pin einzeln anzeigen",
"native_desc": "Mapbox-Kreiscluster (am schnellsten)",
"mst_desc": "Affinitätsnetze über minimalen Spannbaum",
"network_desc": "Verbindungslinien zum nächsten Nachbarn",
"search_languages": "Sprachen suchen…",
"search_religions": "Religionen suchen…",
"reset_all": "Alles zurücksetzen",
"no_languages": "Noch keine Sprachen geladen",
"no_religions": "Noch keine Religionen geladen",
"unadopted": "Nicht adoptiert",
"min": "min",
"max": "max",
"adopted": "Adoptiert",
"adoption": "Adoption",
"engaged": "Engagiert",
"engagement": "Engagement",
"unengaged": "Braucht Engagement",
"language": "Sprache",
"religion": "Religion",
"population": "Bevölkerung"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Affinitätsblock",
"gospel_access": "Zugang zum Evangelium",
"people": "Völker",
"people_name": "Volk (Name)",
"praying": "Betend",
"religion_family": "Religionsfamilie",
"search_language": "Sprache suchen",
"search_people": "Volk suchen",
"search_place": "Ort suchen",
"search_religion": "Religion suchen",
"upgs_in_scope": "UPGs im Bereich",
"countries_touched": "berührte Länder",
"unreached": "unerreicht",
"sum_population": "Σ Bevölkerung",
"sum_praying": "Σ Betende",
"charts": "Diagramme",
"table": "Tabelle",
"cluster": "Cluster",
"country": "Land",
"engagement": "Engagement",
"language": "Sprache",
"language_family": "Sprachfamilie",
"people_group": "Volk",
"population": "Bevölkerung",
"prayer": "Gebet",
"region": "Region",
"religion": "Religion",
"status": "Status",
"adopted": "Adoptiert",
"filters": "Filter",
"gospel_resources": "Evangeliumsressourcen",
"map": "Karte",
"search_placeholder": "Volksgruppen, Orte, Sprachen, Religionen suchen…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Volksgruppen suchen…",
"hide_filters": "Filter ausblenden",
"show_filters": "Filter anzeigen",
"total": "Gesamt",
"showing": "Angezeigt",
"load_more": "Mehr laden",
"full_profile": "Vollständiges Profil",
"wagf_region": "Region",
"wagf_block": "Affinitätsblock",
"rop1": "Volk",
"religion": "Religion",
"exact_filter": "Exakt",
"clear_all": "Alle löschen",
"loading": "Wird geladen…",
"no_results": "Keine Volksgruppen entsprechen diesem Filter.",
"type_to_search": "Zum Suchen tippen...",
"load_failed": "Die Daten konnten nicht geladen werden. Das ist ein Verbindungsproblem, nicht Ihr Filter."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Research controls",
"clustering": "Clustering",
"filters": "Filters",
"pin_dim": "Pin dim",
"export_poster": "Export poster…",
"reload_data": "Reload data",
"off": "Off",
"native": "Native",
"mst": "MST",
"network": "Network",
"off_desc": "Show every pin individually",
"native_desc": "Mapbox circle clusters (fastest)",
"mst_desc": "Minimum-spanning-tree affinity webs",
"network_desc": "Nearest-neighbor connection lines",
"search_languages": "Search languages…",
"search_religions": "Search religions…",
"reset_all": "Reset all",
"no_languages": "No languages loaded yet",
"no_religions": "No religions loaded yet",
"unadopted": "Unadopted",
"min": "min",
"max": "max",
"adopted": "Adopted",
"adoption": "Adoption",
"engaged": "Engaged",
"engagement": "Engagement",
"unengaged": "Unengaged",
"language": "Language",
"religion": "Religion",
"population": "Population"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloc",
"gospel_access": "Gospel Access",
"people": "People",
"people_name": "People (name)",
"praying": "Praying",
"religion_family": "Religion Family",
"search_language": "Search language",
"search_people": "Search people",
"search_place": "Search place",
"search_religion": "Search religion",
"upgs_in_scope": "UPGs in scope",
"countries_touched": "countries touched",
"unreached": "unreached",
"sum_population": "Σ population",
"sum_praying": "Σ praying",
"charts": "Charts",
"table": "Table",
"cluster": "Cluster",
"country": "Country",
"engagement": "Engagement",
"language": "Language",
"language_family": "Language Family",
"people_group": "People Group",
"population": "Population",
"prayer": "Prayer",
"region": "Region",
"religion": "Religion",
"status": "Status",
"adopted": "Adopted",
"filters": "Filters",
"gospel_resources": "Gospel Resources",
"map": "Map",
"search_placeholder": "Search people groups, place, language, religion…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Search people groups…",
"hide_filters": "Hide Filters",
"show_filters": "Show Filters",
"total": "Total",
"showing": "Showing",
"load_more": "Load more",
"full_profile": "Full profile",
"wagf_region": "Region",
"wagf_block": "Affinity Block",
"rop1": "People Group",
"religion": "Religion",
"exact_filter": "Exact",
"clear_all": "Clear All",
"loading": "Loading…",
"no_results": "No people groups match this filter.",
"type_to_search": "Type to search...",
"load_failed": "People-group data could not be loaded. This is a connection problem, not your filter."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Controles de investigación",
"clustering": "Agrupación",
"filters": "Filtros",
"pin_dim": "Atenuar puntos",
"export_poster": "Exportar póster…",
"reload_data": "Recargar datos",
"off": "Desactivado",
"native": "Nativo",
"mst": "MST",
"network": "Red",
"off_desc": "Mostrar cada punto por separado",
"native_desc": "Grupos circulares de Mapbox (lo más rápido)",
"mst_desc": "Redes de afinidad por árbol de expansión mínima",
"network_desc": "Líneas de conexión al vecino más cercano",
"search_languages": "Buscar idiomas…",
"search_religions": "Buscar religiones…",
"reset_all": "Restablecer todo",
"no_languages": "Aún no se han cargado idiomas",
"no_religions": "Aún no se han cargado religiones",
"unadopted": "No adoptado",
"min": "mín",
"max": "máx",
"adopted": "Adoptado",
"adoption": "Adopción",
"engaged": "Comprometido",
"engagement": "Compromiso",
"unengaged": "Necesita implicación",
"language": "Idioma",
"religion": "Religión",
"population": "Población"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloque de afinidad",
"gospel_access": "Acceso al evangelio",
"people": "Pueblos",
"people_name": "Pueblo (nombre)",
"praying": "Orando",
"religion_family": "Familia religiosa",
"search_language": "Buscar idioma",
"search_people": "Buscar pueblo",
"search_place": "Buscar lugar",
"search_religion": "Buscar religión",
"upgs_in_scope": "UPG en el ámbito",
"countries_touched": "países alcanzados",
"unreached": "no alcanzados",
"sum_population": "Σ población",
"sum_praying": "Σ orando",
"charts": "Gráficos",
"table": "Tabla",
"cluster": "Clúster",
"country": "País",
"engagement": "Compromiso",
"language": "Idioma",
"language_family": "Familia lingüística",
"people_group": "Pueblo",
"population": "Población",
"prayer": "Oración",
"region": "Región",
"religion": "Religión",
"status": "Estado",
"adopted": "Adoptado",
"filters": "Filtros",
"gospel_resources": "Recursos del Evangelio",
"map": "Mapa",
"search_placeholder": "Buscar pueblos, lugares, idiomas, religiones…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Buscar pueblos…",
"hide_filters": "Ocultar filtros",
"show_filters": "Mostrar filtros",
"total": "Total",
"showing": "Mostrando",
"load_more": "Cargar más",
"full_profile": "Perfil completo",
"wagf_region": "Región",
"wagf_block": "Bloque de afinidad",
"rop1": "Pueblo",
"religion": "Religión",
"exact_filter": "Exacto",
"clear_all": "Borrar todo",
"loading": "Cargando…",
"no_results": "Ningún pueblo coincide con este filtro.",
"type_to_search": "Escribe para buscar...",
"load_failed": "No se pudieron cargar los datos de pueblos. Es un problema de conexión, no de tu filtro."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Commandes de recherche",
"clustering": "Regroupement",
"filters": "Filtres",
"pin_dim": "Atténuation des points",
"export_poster": "Exporter l’affiche…",
"reload_data": "Recharger les données",
"off": "Désactivé",
"native": "Natif",
"mst": "MST",
"network": "Réseau",
"off_desc": "Afficher chaque point séparément",
"native_desc": "Groupes circulaires Mapbox (le plus rapide)",
"mst_desc": "Réseaux d’affinité par arbre couvrant minimal",
"network_desc": "Lignes vers le plus proche voisin",
"search_languages": "Rechercher des langues…",
"search_religions": "Rechercher des religions…",
"reset_all": "Tout réinitialiser",
"no_languages": "Aucune langue chargée pour l’instant",
"no_religions": "Aucune religion chargée pour l’instant",
"unadopted": "Non adopté",
"min": "min",
"max": "max",
"adopted": "Adopté",
"adoption": "Adoption",
"engaged": "Engagé",
"engagement": "Engagement",
"unengaged": "Besoin d'engagement",
"language": "Langue",
"religion": "Religion",
"population": "Population"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloc d’affinité",
"gospel_access": "Accès à l’Évangile",
"people": "Peuples",
"people_name": "Peuple (nom)",
"praying": "En prière",
"religion_family": "Famille religieuse",
"search_language": "Rechercher une langue",
"search_people": "Rechercher un peuple",
"search_place": "Rechercher un lieu",
"search_religion": "Rechercher une religion",
"upgs_in_scope": "UPG dans le périmètre",
"countries_touched": "pays touchés",
"unreached": "non atteints",
"sum_population": "Σ population",
"sum_praying": "Σ en prière",
"charts": "Graphiques",
"table": "Tableau",
"cluster": "Grappe",
"country": "Pays",
"engagement": "Engagement",
"language": "Langue",
"language_family": "Famille linguistique",
"people_group": "Peuple",
"population": "Population",
"prayer": "Prière",
"region": "Région",
"religion": "Religion",
"status": "Statut",
"adopted": "Adopté",
"filters": "Filtres",
"gospel_resources": "Ressources de l'Évangile",
"map": "Carte",
"search_placeholder": "Rechercher peuples, lieux, langues, religions…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Rechercher des peuples…",
"hide_filters": "Masquer les filtres",
"show_filters": "Afficher les filtres",
"total": "Total",
"showing": "Affichage",
"load_more": "Charger plus",
"full_profile": "Profil complet",
"wagf_region": "Région",
"wagf_block": "Bloc d'affinité",
"rop1": "Peuple",
"religion": "Religion",
"exact_filter": "Exact",
"clear_all": "Tout effacer",
"loading": "Chargement…",
"no_results": "Aucun peuple ne correspond à ce filtre.",
"type_to_search": "Tapez pour rechercher...",
"load_failed": "Les données n’ont pas pu être chargées. C’est un problème de connexion, pas votre filtre."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "शोध नियंत्रण",
"clustering": "क्लस्टरिंग",
"filters": "फ़िल्टर",
"pin_dim": "पिन धुंधलापन",
"export_poster": "पोस्टर निर्यात करें…",
"reload_data": "डेटा फिर से लोड करें",
"off": "बंद",
"native": "नेटिव",
"mst": "MST",
"network": "नेटवर्क",
"off_desc": "हर पिन अलग-अलग दिखाएँ",
"native_desc": "Mapbox वृत्त क्लस्टर (सबसे तेज़)",
"mst_desc": "न्यूनतम स्पैनिंग ट्री आधारित जाल",
"network_desc": "निकटतम पड़ोसी को जोड़ने वाली रेखाएँ",
"search_languages": "भाषाएँ खोजें…",
"search_religions": "धर्म खोजें…",
"reset_all": "सब रीसेट करें",
"no_languages": "अभी कोई भाषा लोड नहीं हुई",
"no_religions": "अभी कोई धर्म लोड नहीं हुआ",
"unadopted": "अ-गोद लिया",
"min": "न्यून",
"max": "अधि",
"adopted": "गोद लिया गया",
"adoption": "गोद लेना",
"engaged": "संपर्कित",
"engagement": "सहभागिता",
"unengaged": "सहभागिता चाहिए",
"language": "भाषा",
"religion": "धर्म",
"population": "जनसंख्या"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "समानता समूह",
"gospel_access": "सुसमाचार पहुँच",
"people": "जनसमूह",
"people_name": "जनसमूह (नाम)",
"praying": "प्रार्थना कर रहे",
"religion_family": "धर्म परिवार",
"search_language": "भाषा खोजें",
"search_people": "जनसमूह खोजें",
"search_place": "स्थान खोजें",
"search_religion": "धर्म खोजें",
"upgs_in_scope": "दायरे में UPG",
"countries_touched": "छुए गए देश",
"unreached": "अनपहुँचे",
"sum_population": "Σ जनसंख्या",
"sum_praying": "Σ प्रार्थना",
"charts": "चार्ट",
"table": "तालिका",
"cluster": "क्लस्टर",
"country": "देश",
"engagement": "सहभागिता",
"language": "भाषा",
"language_family": "भाषा परिवार",
"people_group": "जनसमूह",
"population": "जनसंख्या",
"prayer": "प्रार्थना",
"region": "क्षेत्र",
"religion": "धर्म",
"status": "स्थिति",
"adopted": "गोद लिया गया",
"filters": "फ़िल्टर",
"gospel_resources": "सुसमाचार संसाधन",
"map": "नक्शा",
"search_placeholder": "जनसमूह, स्थान, भाषा, धर्म खोजें…"
}

View file

@ -0,0 +1,19 @@
{
"search": "जनसमूह खोजें…",
"hide_filters": "फ़िल्टर छिपाएँ",
"show_filters": "फ़िल्टर दिखाएँ",
"total": "कुल",
"showing": "दिखा रहे हैं",
"load_more": "और देखें",
"full_profile": "पूरी प्रोफ़ाइल",
"wagf_region": "क्षेत्र",
"wagf_block": "समानता समूह",
"rop1": "जनसमूह",
"religion": "धर्म",
"exact_filter": "सटीक",
"clear_all": "सब हटाएँ",
"loading": "लोड हो रहा है…",
"no_results": "इस फ़िल्टर से कोई जनसमूह मेल नहीं खाता।",
"type_to_search": "खोजने के लिए लिखें...",
"load_failed": "जनसमूह डेटा लोड नहीं हो सका। यह कनेक्शन की समस्या है, आपके फ़िल्टर की नहीं।"
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Controlli di ricerca",
"clustering": "Raggruppamento",
"filters": "Filtri",
"pin_dim": "Opacità dei punti",
"export_poster": "Esporta poster…",
"reload_data": "Ricarica i dati",
"off": "Disattivato",
"native": "Nativo",
"mst": "MST",
"network": "Rete",
"off_desc": "Mostra ogni punto singolarmente",
"native_desc": "Cluster circolari Mapbox (il più veloce)",
"mst_desc": "Reti di affinità ad albero ricoprente minimo",
"network_desc": "Linee verso il vicino più prossimo",
"search_languages": "Cerca lingue…",
"search_religions": "Cerca religioni…",
"reset_all": "Reimposta tutto",
"no_languages": "Nessuna lingua ancora caricata",
"no_religions": "Nessuna religione ancora caricata",
"unadopted": "Non adottato",
"min": "min",
"max": "max",
"adopted": "Adottato",
"adoption": "Adozione",
"engaged": "Coinvolto",
"engagement": "Impegno",
"unengaged": "Necessita impegno",
"language": "Lingua",
"religion": "Religione",
"population": "Popolazione"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Blocco di affinità",
"gospel_access": "Accesso al vangelo",
"people": "Popoli",
"people_name": "Popolo (nome)",
"praying": "In preghiera",
"religion_family": "Famiglia religiosa",
"search_language": "Cerca lingua",
"search_people": "Cerca popolo",
"search_place": "Cerca luogo",
"search_religion": "Cerca religione",
"upgs_in_scope": "UPG nell’ambito",
"countries_touched": "paesi toccati",
"unreached": "non raggiunti",
"sum_population": "Σ popolazione",
"sum_praying": "Σ in preghiera",
"charts": "Grafici",
"table": "Tabella",
"cluster": "Cluster",
"country": "Paese",
"engagement": "Impegno",
"language": "Lingua",
"language_family": "Famiglia linguistica",
"people_group": "Popolo",
"population": "Popolazione",
"prayer": "Preghiera",
"region": "Regione",
"religion": "Religione",
"status": "Stato",
"adopted": "Adottato",
"filters": "Filtri",
"gospel_resources": "Risorse del Vangelo",
"map": "Mappa",
"search_placeholder": "Cerca popoli, luoghi, lingue, religioni…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Cerca popoli…",
"hide_filters": "Nascondi filtri",
"show_filters": "Mostra filtri",
"total": "Totale",
"showing": "Mostrati",
"load_more": "Carica altro",
"full_profile": "Profilo completo",
"wagf_region": "Regione",
"wagf_block": "Blocco di affinità",
"rop1": "Popolo",
"religion": "Religione",
"exact_filter": "Esatto",
"clear_all": "Cancella tutto",
"loading": "Caricamento…",
"no_results": "Nessun popolo corrisponde a questo filtro.",
"type_to_search": "Digita per cercare...",
"load_failed": "Impossibile caricare i dati dei popoli. È un problema di connessione, non del filtro."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Controlos de pesquisa",
"clustering": "Agrupamento",
"filters": "Filtros",
"pin_dim": "Esbater pontos",
"export_poster": "Exportar cartaz…",
"reload_data": "Recarregar dados",
"off": "Desligado",
"native": "Nativo",
"mst": "MST",
"network": "Rede",
"off_desc": "Mostrar cada ponto individualmente",
"native_desc": "Grupos circulares Mapbox (mais rápido)",
"mst_desc": "Redes de afinidade por árvore geradora mínima",
"network_desc": "Linhas para o vizinho mais próximo",
"search_languages": "Pesquisar idiomas…",
"search_religions": "Pesquisar religiões…",
"reset_all": "Repor tudo",
"no_languages": "Ainda não há idiomas carregados",
"no_religions": "Ainda não há religiões carregadas",
"unadopted": "Não adotado",
"min": "mín",
"max": "máx",
"adopted": "Adotado",
"adoption": "Adoção",
"engaged": "Engajado",
"engagement": "Engajamento",
"unengaged": "Precisa de envolvimento",
"language": "Idioma",
"religion": "Religião",
"population": "População"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloco de afinidade",
"gospel_access": "Acesso ao evangelho",
"people": "Povos",
"people_name": "Povo (nome)",
"praying": "A orar",
"religion_family": "Família religiosa",
"search_language": "Pesquisar idioma",
"search_people": "Pesquisar povo",
"search_place": "Pesquisar lugar",
"search_religion": "Pesquisar religião",
"upgs_in_scope": "UPG no âmbito",
"countries_touched": "países alcançados",
"unreached": "não alcançados",
"sum_population": "Σ população",
"sum_praying": "Σ a orar",
"charts": "Gráficos",
"table": "Tabela",
"cluster": "Agrupamento",
"country": "País",
"engagement": "Engajamento",
"language": "Idioma",
"language_family": "Família linguística",
"people_group": "Povo",
"population": "População",
"prayer": "Oração",
"region": "Região",
"religion": "Religião",
"status": "Status",
"adopted": "Adotado",
"filters": "Filtros",
"gospel_resources": "Recursos do Evangelho",
"map": "Mapa",
"search_placeholder": "Pesquisar povos, lugares, idiomas, religiões…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Pesquisar povos…",
"hide_filters": "Ocultar filtros",
"show_filters": "Mostrar filtros",
"total": "Total",
"showing": "Mostrando",
"load_more": "Carregar mais",
"full_profile": "Perfil completo",
"wagf_region": "Região",
"wagf_block": "Bloco de afinidade",
"rop1": "Povo",
"religion": "Religião",
"exact_filter": "Exato",
"clear_all": "Limpar tudo",
"loading": "A carregar…",
"no_results": "Nenhum povo corresponde a este filtro.",
"type_to_search": "Escreva para pesquisar...",
"load_failed": "Não foi possível carregar os dados dos povos. É um problema de ligação, não do seu filtro."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Comenzi de cercetare",
"clustering": "Grupare",
"filters": "Filtre",
"pin_dim": "Estompare puncte",
"export_poster": "Exportă posterul…",
"reload_data": "Reîncarcă datele",
"off": "Oprit",
"native": "Nativ",
"mst": "MST",
"network": "Rețea",
"off_desc": "Arată fiecare punct separat",
"native_desc": "Grupuri circulare Mapbox (cel mai rapid)",
"mst_desc": "Rețele de afinitate prin arbore minim de acoperire",
"network_desc": "Linii către cel mai apropiat vecin",
"search_languages": "Caută limbi…",
"search_religions": "Caută religii…",
"reset_all": "Resetează tot",
"no_languages": "Nicio limbă încărcată încă",
"no_religions": "Nicio religie încărcată încă",
"unadopted": "Neadoptat",
"min": "min",
"max": "max",
"adopted": "Adoptat",
"adoption": "Adopție",
"engaged": "Implicat",
"engagement": "Implicare",
"unengaged": "Necesită implicare",
"language": "Limbă",
"religion": "Religie",
"population": "Populație"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Bloc de afinitate",
"gospel_access": "Acces la Evanghelie",
"people": "Popoare",
"people_name": "Popor (nume)",
"praying": "Se roagă",
"religion_family": "Familie religioasă",
"search_language": "Caută limbă",
"search_people": "Caută popor",
"search_place": "Caută loc",
"search_religion": "Caută religie",
"upgs_in_scope": "UPG în domeniu",
"countries_touched": "țări atinse",
"unreached": "neatinși",
"sum_population": "Σ populație",
"sum_praying": "Σ se roagă",
"charts": "Grafice",
"table": "Tabel",
"cluster": "Grup",
"country": "Țară",
"engagement": "Implicare",
"language": "Limbă",
"language_family": "Familie de limbi",
"people_group": "Popor",
"population": "Populație",
"prayer": "Rugăciune",
"region": "Regiune",
"religion": "Religie",
"status": "Stare",
"adopted": "Adoptat",
"filters": "Filtre",
"gospel_resources": "Resurse ale Evangheliei",
"map": "Hartă",
"search_placeholder": "Caută popoare, locuri, limbi, religii…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Caută popoare…",
"hide_filters": "Ascunde filtrele",
"show_filters": "Arată filtrele",
"total": "Total",
"showing": "Se afișează",
"load_more": "Încarcă mai mult",
"full_profile": "Profil complet",
"wagf_region": "Regiune",
"wagf_block": "Bloc de afinitate",
"rop1": "Popor",
"religion": "Religie",
"exact_filter": "Exact",
"clear_all": "Șterge tot",
"loading": "Se încarcă…",
"no_results": "Niciun popor nu corespunde acestui filtru.",
"type_to_search": "Scrie pentru a căuta...",
"load_failed": "Datele despre popoare nu au putut fi încărcate. Este o problemă de conexiune, nu a filtrului."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "Инструменты исследования",
"clustering": "Кластеризация",
"filters": "Фильтры",
"pin_dim": "Прозрачность точек",
"export_poster": "Экспорт постера…",
"reload_data": "Обновить данные",
"off": "Выкл.",
"native": "Встроенная",
"mst": "MST",
"network": "Сеть",
"off_desc": "Показывать каждую точку отдельно",
"native_desc": "Круговые кластеры Mapbox (быстрее всего)",
"mst_desc": "Сети родства по минимальному остовному дереву",
"network_desc": "Линии до ближайшего соседа",
"search_languages": "Поиск языков…",
"search_religions": "Поиск религий…",
"reset_all": "Сбросить всё",
"no_languages": "Языки ещё не загружены",
"no_religions": "Религии ещё не загружены",
"unadopted": "Не усыновлён",
"min": "мин",
"max": "макс",
"adopted": "Ответственность взята",
"adoption": "Усыновление",
"engaged": "Вовлечён",
"engagement": "Вовлечённость",
"unengaged": "Нуждается в вовлечённости",
"language": "Язык",
"religion": "Религия",
"population": "Население"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "Блок родства",
"gospel_access": "Доступ к Евангелию",
"people": "Народы",
"people_name": "Народ (название)",
"praying": "Молятся",
"religion_family": "Семья религий",
"search_language": "Поиск языка",
"search_people": "Поиск народа",
"search_place": "Поиск места",
"search_religion": "Поиск религии",
"upgs_in_scope": "UPG в выборке",
"countries_touched": "затронутых стран",
"unreached": "неохваченные",
"sum_population": "Σ население",
"sum_praying": "Σ молящихся",
"charts": "Графики",
"table": "Таблица",
"cluster": "Кластер",
"country": "Страна",
"engagement": "Вовлечённость",
"language": "Язык",
"language_family": "Языковая семья",
"people_group": "Народ",
"population": "Население",
"prayer": "Молитва",
"region": "Регион",
"religion": "Религия",
"status": "Статус",
"adopted": "Ответственность взята",
"filters": "Фильтры",
"gospel_resources": "Евангельские ресурсы",
"map": "Карта",
"search_placeholder": "Поиск народов, мест, языков, религий…"
}

View file

@ -0,0 +1,19 @@
{
"search": "Поиск народов…",
"hide_filters": "Скрыть фильтры",
"show_filters": "Показать фильтры",
"total": "Всего",
"showing": "Показано",
"load_more": "Показать ещё",
"full_profile": "Полный профиль",
"wagf_region": "Регион",
"wagf_block": "Блок родства",
"rop1": "Народ",
"religion": "Религия",
"exact_filter": "Точное совпадение",
"clear_all": "Очистить всё",
"loading": "Загрузка…",
"no_results": "Нет народов, соответствующих этому фильтру.",
"type_to_search": "Введите для поиска...",
"load_failed": "Не удалось загрузить данные о народах. Это проблема соединения, а не фильтра."
}

View file

@ -0,0 +1,32 @@
{
"research_controls": "研究控件",
"clustering": "聚类",
"filters": "筛选",
"pin_dim": "圆点淡化",
"export_poster": "导出海报…",
"reload_data": "重新加载数据",
"off": "关闭",
"native": "原生",
"mst": "MST",
"network": "网络",
"off_desc": "逐个显示每个圆点",
"native_desc": "Mapbox 圆形聚类(最快)",
"mst_desc": "最小生成树亲缘网络",
"network_desc": "最近邻连接线",
"search_languages": "搜索语言…",
"search_religions": "搜索宗教…",
"reset_all": "全部重置",
"no_languages": "尚未加载语言",
"no_religions": "尚未加载宗教",
"unadopted": "未认领",
"min": "最小",
"max": "最大",
"adopted": "已认领",
"adoption": "认领",
"engaged": "已参与",
"engagement": "参与",
"unengaged": "需要参与",
"language": "语言",
"religion": "宗教",
"population": "人口"
}

View file

@ -0,0 +1,35 @@
{
"bloc": "亲缘板块",
"gospel_access": "福音可及性",
"people": "民族",
"people_name": "民族(名称)",
"praying": "正在代祷",
"religion_family": "宗教族系",
"search_language": "搜索语言",
"search_people": "搜索民族",
"search_place": "搜索地点",
"search_religion": "搜索宗教",
"upgs_in_scope": "范围内的 UPG",
"countries_touched": "涉及国家",
"unreached": "未得之民",
"sum_population": "Σ 人口",
"sum_praying": "Σ 代祷",
"charts": "图表",
"table": "表格",
"cluster": "民族群",
"country": "國家",
"engagement": "参与",
"language": "语言",
"language_family": "语系",
"people_group": "民族",
"population": "人口",
"prayer": "祷告",
"region": "区域",
"religion": "宗教",
"status": "状态",
"adopted": "已认领",
"filters": "筛选",
"gospel_resources": "福音资源",
"map": "地图",
"search_placeholder": "搜索民族、地点、语言、宗教…"
}

View file

@ -0,0 +1,19 @@
{
"search": "搜索民族…",
"hide_filters": "隐藏筛选",
"show_filters": "显示筛选",
"total": "总计",
"showing": "显示",
"load_more": "加载更多",
"full_profile": "完整档案",
"wagf_region": "区域",
"wagf_block": "亲缘板块",
"rop1": "民族",
"religion": "宗教",
"exact_filter": "精确",
"clear_all": "全部清除",
"loading": "加载中…",
"no_results": "没有符合此筛选条件的民族。",
"type_to_search": "输入以搜索...",
"load_failed": "无法加载民族数据。这是连接问题,而不是筛选问题。"
}

View file

@ -9,11 +9,22 @@
* migrated bundle behaves identically.
*/
import { lbl } from './src/composables/useDashboardAggregations.js'
import { tStatic } from '@map/i18n/index.js'
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
import { canonicalFamilyName } from '@map/colors/language-family.js'
import langFamilyByIso from '@map/data/langFamilyByIso.json'
import { getPrayerLevel, LABELS as PRAYER_LABELS } from '@map/colors/prayer-progress.js'
// ★ EVERY LABEL IN THIS CONFIG WAS ENGLISH IN ALL ELEVEN LOCALES. Measured under ?lang=ru on Big
// Picture: the facet rail, chart titles, KPI strip and table headers read Region / Bloc / Country /
// Religion while the map beside them was fully translated.
// They are declared with GETTER SYNTAX (`get label() {...}`) rather than resolved values, because
// this module is imported ONCE at load while the locale is only known later (?lang=, the host's
// profile-config, <html lang>). A getter resolves on every read, so one config object serves
// whichever locale asks — and note that SPREADING a getter would not work: the spread copies the
// resolved value, not the accessor, which is the trap this comment exists to stop.
// tStatic, not useI18n: plain config, no component instance, and mounts that install no i18n.
// same numeric coercion the DataTable uses for its num columns
const num = (v) => { if (typeof v === 'number') return v; const n = parseFloat(String(v ?? '').replace(/[^0-9.\-]/g, '')); return isNaN(n) ? null : n }
@ -67,14 +78,14 @@ export default {
// Returns the human LABEL, which is both the display text and the bus value: the same
// value-is-label contract every non-People axis follows.
prayer_level: {
label: 'Prayer',
get label() { return tStatic('dashboard.prayer') || 'Prayer' },
get: (r) => {
const n = r?.people_praying
const raw = n && typeof n === 'object' ? n.value : n
return PRAYER_LABELS[getPrayerLevel({ peoplePraying: raw ?? null })]
},
},
religion: { label: 'Religion', get: (r) => lbl(r.religion) },
religion: { get label() { return tStatic('dashboard.religion') || 'Religion' }, get: (r) => lbl(r.religion) },
// ── Religion TIER 1 (card BUILD-STACKED-TREE-LEGEND) ──
// The Doxa API's religion code is 1-3 chars whose FIRST LETTER is the family
// ('MSN' → 'M' → Islam), which is precisely how the cap-3 map paints religion pins
@ -89,7 +100,7 @@ export default {
// Measured live (2,107 rows, 2026-09-20): 10 families, all named in RELIGION_FAMILIES,
// family totals sum exactly to the leaf-code totals.
religion_family: {
label: 'Religion Family',
get label() { return tStatic('dashboard.religion_family') || 'Religion Family' },
// ★ NO LONGER dashboard-only (2026-09-23). This facet's value is DERIVED ("Hinduism" from
// the religion code's first letter), which for months meant no grid row field could match
// it -- the honest stopgap was a dashboardOnly flag the rail rendered as "updates this
@ -109,13 +120,13 @@ export default {
return RELIGION_FAMILIES[c] ? c : ''
},
},
wagf_region: { label: 'Region', get: (r) => lbl(r.wagf_region) },
wagf_region: { get label() { return tStatic('dashboard.region') || 'Region' }, get: (r) => lbl(r.wagf_region) },
// "Bloc" (NOT "Block") — a bloc is a SUBSET of a region, per the WAGF place hierarchy
// region > bloc > country. Display label only; the facet KEY stays `wagf_block` because the
// API field, the fieldMappings, and every consumer prop (wagfBlock) are spelled that way.
wagf_block: { label: 'Bloc', get: (r) => lbl(r.wagf_block) },
country_code: { label: 'Country', get: (r) => lbl(r.country_code), geoIso: (r) => r.country_code && r.country_code.value },
engagement_status: { label: 'Status', get: (r) => lbl(r.engagement_status) },
wagf_block: { get label() { return tStatic('dashboard.bloc') || 'Bloc' }, get: (r) => lbl(r.wagf_block) },
country_code: { get label() { return tStatic('dashboard.country') || 'Country' }, get: (r) => lbl(r.country_code), geoIso: (r) => r.country_code && r.country_code.value },
engagement_status: { get label() { return tStatic('dashboard.status') || 'Status' }, get: (r) => lbl(r.engagement_status) },
// ── THE PHASES OF ENGAGEMENT ──────────────────────────────────────────────────────────
// `engagement_status` above is a BINARY flag — measured live over 2,108 rows it is 2,081
// Unengaged / 27 Engaged. imb_gsec is the ordinal ladder underneath it (present 2,106/2,108):
@ -130,7 +141,7 @@ export default {
// A REAL row field, so unlike language_family/religion_family this one CAN move the grid: 5
// distinct {value,label} pairs with no value-vs-label cross-collision ('0' never reads as a
// label), which is the same safety property that made country_code and cluster wireable.
gospel_access: { label: 'Gospel Access', get: (r) => lbl(r.imb_gsec) },
gospel_access: { get label() { return tStatic('dashboard.gospel_access') || 'Gospel Access' }, get: (r) => lbl(r.imb_gsec) },
// ── Language TIER 1 — API value FIRST, ISO lookup as FALLBACK ──
// MEASURED on the live API (2,107 rows, 2026-09-20): `imb_language_family` is null on
// 2,073 of them — it is populated for just 34 rows (1.6%). Read alone, the Language tree
@ -148,7 +159,7 @@ export default {
// the value this returns is both the display label AND a direct palette key — one projection,
// byte-match contract intact.
language_family: {
label: 'Language Family',
get label() { return tStatic('dashboard.language_family') || 'Language Family' },
// ★ NO LONGER dashboard-only (2026-09-23) — same arc as religion_family above: the value
// is DERIVED (imb_language_family label, else langFamilyByIso over the ISO code), and the
// grid now runs the SAME derivation from the SAME @map modules (DERIVED_AXES), so the
@ -161,7 +172,7 @@ export default {
return hit ? canonicalFamilyName(hit) : ''
},
},
primary_language: { label: 'Language', get: (r) => lbl(r.primary_language) },
primary_language: { get label() { return tStatic('dashboard.language') || 'Language' }, get: (r) => lbl(r.primary_language) },
// People tier accessors — key by CODE (DashboardFacetRail resolves code→human name via
// useAffinityBlockLegendData). Mirrors research-map's affinity-block → cluster → PG hierarchy.
//
@ -188,19 +199,19 @@ export default {
// they cannot drift the way a cross-source name lookup could. SearchFilterHeader already
// supports value≠label (it resolves a chip's text via `o.value === f.value`).
affinity_bloc: {
label: 'People',
get label() { return tStatic('dashboard.people') || 'People' },
get: (r) => code(r.rop1),
optionLabel: (r) => lbl(r.rop1),
},
cluster: { label: 'Cluster', get: (r) => code(r.imb_reg_of_people_2) },
people_group: { label: 'People Group', get: (r) => code(r.imb_reg_of_people_25) },
cluster: { get label() { return tStatic('dashboard.cluster') || 'Cluster' }, get: (r) => code(r.imb_reg_of_people_2) },
people_group: { get label() { return tStatic('dashboard.people_group') || 'People Group' }, get: (r) => code(r.imb_reg_of_people_25) },
// SEARCH-ONLY projection of the People name. NOT an axis: no control list names it
// (`facetRail.drill`, `charts[].facet` and `researchFilters.dropdowns` all name
// `affinity_bloc`), and nothing enumerates `facets` to render a control — every consumer
// looks a key UP. It exists solely so free-text search keeps matching "South Asian" rather
// than the code 'A012' (buildSearchAccessors resolves searchKeys through facets first).
// Never add this key to a control list: that would reintroduce the duplicate axis.
rop1_name: { label: 'People (name)', get: (r) => lbl(r.rop1) },
rop1_name: { get label() { return tStatic('dashboard.people_name') || 'People (name)' }, get: (r) => lbl(r.rop1) },
},
// ── facet rail sections ── (DashboardFacetRail) — ONE centralized semantic tree mirroring the
@ -241,18 +252,18 @@ export default {
// ── KPI strip ── (computed by useDashboardShell; mirrors the original 6 stat computeds)
kpis: [
{ id: 'total', label: 'UPGs in scope', kind: 'count', showDelta: true },
{ id: 'pop', label: 'Σ population', kind: 'sum', field: 'population' },
{ id: 'total', get label() { return tStatic('dashboard.upgs_in_scope') || 'UPGs in scope' }, kind: 'count', showDelta: true },
{ id: 'pop', get label() { return tStatic('dashboard.sum_population') || 'Σ population' }, kind: 'sum', field: 'population' },
// ★ NO '%' IN A `kind:'pct'` LABEL. kpiText() returns k.value unchanged for pct, and that
// value ALREADY carries its percent sign — so a label that also carries one renders
// "99% % UNREACHED". Found by reading the rendered screenshot, not by reading the code:
// each half looks correct in isolation and only the composition is wrong, which is why it
// survived review. The label says WHAT is measured; the value says how much.
{ id: 'unreached', label: 'unreached', kind: 'pct',
{ id: 'unreached', get label() { return tStatic('dashboard.unreached') || 'unreached' }, kind: 'pct',
test: (r) => !/engaged/i.test(lbl(r.engagement_status)) || /unengaged/i.test(lbl(r.engagement_status)) },
{ id: 'countries', label: 'countries touched', kind: 'distinct', field: 'country_code', via: 'geoIso' },
{ id: 'praying', label: 'Σ praying', kind: 'sum', field: 'people_praying' },
{ id: 'adopted', label: 'adopted', kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 },
{ id: 'countries', get label() { return tStatic('dashboard.countries_touched') || 'countries touched' }, kind: 'distinct', field: 'country_code', via: 'geoIso' },
{ id: 'praying', get label() { return tStatic('dashboard.sum_praying') || 'Σ praying' }, kind: 'sum', field: 'people_praying' },
{ id: 'adopted', get label() { return tStatic('dashboard.adopted') || 'adopted' }, kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 },
],
// ── table columns ── (DashboardDataTable)
@ -284,13 +295,13 @@ export default {
// Deliberately NOT pixel widths: a fixed px column breaks at a viewport you did not test, while a
// weight degrades proportionally everywhere.
columns: [
{ key: 'name', label: 'People Group', get: (r) => r.name || '', type: 'str', w: 1.34 },
{ key: 'country', label: 'Country', get: (r) => lbl(r.country_code), type: 'str', w: 1.04 },
{ key: 'region', label: 'Region', get: (r) => lbl(r.wagf_region), type: 'str', w: 1.04 },
{ key: 'language', label: 'Language', get: (r) => lbl(r.primary_language), type: 'str', w: 1.04 },
{ key: 'religion', label: 'Religion', get: (r) => lbl(r.religion), type: 'str', w: 1.04 },
{ key: 'population', label: 'Population', get: (r) => num(r.population), type: 'num', w: 0.86 },
{ key: 'people_praying', label: 'Praying', get: (r) => num(r.people_praying), type: 'num', w: 0.66 },
{ key: 'name', get label() { return tStatic('dashboard.people_group') || 'People Group' }, get: (r) => r.name || '', type: 'str', w: 1.34 },
{ key: 'country', get label() { return tStatic('dashboard.country') || 'Country' }, get: (r) => lbl(r.country_code), type: 'str', w: 1.04 },
{ key: 'region', get label() { return tStatic('dashboard.region') || 'Region' }, get: (r) => lbl(r.wagf_region), type: 'str', w: 1.04 },
{ key: 'language', get label() { return tStatic('dashboard.language') || 'Language' }, get: (r) => lbl(r.primary_language), type: 'str', w: 1.04 },
{ key: 'religion', get label() { return tStatic('dashboard.religion') || 'Religion' }, get: (r) => lbl(r.religion), type: 'str', w: 1.04 },
{ key: 'population', get label() { return tStatic('dashboard.population') || 'Population' }, get: (r) => num(r.population), type: 'num', w: 0.86 },
{ key: 'people_praying', get label() { return tStatic('dashboard.praying') || 'Praying' }, get: (r) => num(r.people_praying), type: 'num', w: 0.66 },
{ key: 'engagement', label: 'Engagement', get: (r) => lbl(r.engagement_status), type: 'str', w: 0.97 },
],
@ -477,7 +488,7 @@ export default {
// Status tree section, because the grid still filters on it and removing the accessor would break
// FACET_TO_PROP. What changed is only whether it is offered as a DROPDOWN in the filter row.
dropdowns: ['prayer_level', 'affinity_bloc', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
searchPlaceholder: 'Search people groups, place, language, religion…',
get searchPlaceholder() { return tStatic('dashboard.search_placeholder') || 'Search people groups, place, language, religion…' },
},
// `rop1_name` (not `affinity_bloc`) so free-text search matches the People NAME — DX-life's
// Fuse key set includes 'rop1.label' (UupgsList.vue:299), and searching the axis's canonical
@ -493,13 +504,13 @@ export default {
// results go to selectionSet; the rest to filterSet) — the kind-switch itself stays in
// the composable. See src/components/dashboards/README.md.
search: [
{ id: 'people', label: 'Search people', context: 'people-groups',
{ id: 'people', get label() { return tStatic('dashboard.search_people') || 'Search people' }, context: 'people-groups',
kinds: ['affinity-bloc', 'cluster', 'people-group'] },
{ id: 'place', label: 'Search place', context: 'regions',
{ id: 'place', get label() { return tStatic('dashboard.search_place') || 'Search place' }, context: 'regions',
kinds: ['region', 'country'] },
{ id: 'language', label: 'Search language', context: 'language',
{ id: 'language', get label() { return tStatic('dashboard.search_language') || 'Search language' }, context: 'language',
kinds: ['language-family', 'language', 'dialect'] },
{ id: 'religion', label: 'Search religion', context: 'religion',
{ id: 'religion', get label() { return tStatic('dashboard.search_religion') || 'Search religion' }, context: 'religion',
kinds: ['religion'] },
],
}

View file

@ -1,4 +1,5 @@
<script setup>
import { tStatic } from '@map/i18n/index.js'
/**
* ChartsRail — RDASH phase 5: cross-filtered charts rail. Religion donut + Region donut (doxa-region
* palette) + Top-country bar, all re-aggregating on filterSet and brushable back into it (click a
@ -31,10 +32,10 @@ import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../
* stub. Making it configurable now would be an abstraction over a single real case.
*/
const tabs = [
{ id: 'charts', label: 'Charts' },
{ id: 'charts', get label() { return tStatic('dashboard.charts') || 'Charts' } },
{
id: 'gospel',
label: 'Gospel Resources',
get label() { return tStatic('dashboard.gospel_resources') || 'Gospel Resources' },
disabled: true,
why: 'Waiting on three decisions: the official lostness number, key-less vs a registered Joshua Project key, and the UI shape. See IDEA-GOSPEL-RESOURCES-INTEGRATION-STRATEGY.',
},

View file

@ -31,6 +31,7 @@ import { useCsvExport } from './useCsvExport.js'
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json'
import { tStatic } from '@map/i18n/index.js'
// tiny lodash-free mapValues: { k: v } -> { k: fn(v, k) }
const mapValues = (obj, fn) =>
@ -159,7 +160,14 @@ function useResponsiveShell(bus) {
const slimRail = ref(false)
const slimFacet = ref(false)
const mobileView = ref('map')
const SEG = [{ k: 'map', l: 'Map' }, { k: 'facets', l: 'Filters' }, { k: 'table', l: 'Table' }, { k: 'charts', l: 'Charts' }]
// Getters, for the same reason dashboard.config.js uses them: this array is built before the
// locale is necessarily known, and a resolved string would freeze the first locale seen.
const SEG = [
{ k: 'map', get l() { return tStatic('dashboard.map') || 'Map' } },
{ k: 'facets', get l() { return tStatic('dashboard.filters') || 'Filters' } },
{ k: 'table', get l() { return tStatic('dashboard.table') || 'Table' } },
{ k: 'charts', get l() { return tStatic('dashboard.charts') || 'Charts' } },
]
let ro = null
const tableCollapsed = ref(false)