diff --git a/plugin-designs/key-cities/cities-list.vue b/plugin-designs/key-cities/cities-list.vue
index 47d20964f..7ec808445 100644
--- a/plugin-designs/key-cities/cities-list.vue
+++ b/plugin-designs/key-cities/cities-list.vue
@@ -26,7 +26,9 @@
* PERSECUTION-SAFETY: locations of PEOPLE GROUPS, which is public ethnographic
* data. No church, believer or meeting-point information, and it must not gain any.
*/
-import { computed, inject, ref, onMounted } from 'vue'
+import { computed, inject, ref, onMounted, h } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { sentenceParts, keep, ltr } from '@map/composables/useSentence.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
@@ -40,7 +42,7 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
// it and block first paint. Measured on the other two apps: static imports took
// them to 2.0MB and 2.9MB before this fix.
const table = ref({ rows: [] })
-const loadErr = ref(null)
+const loadErr = ref(null) // the engine's or the server's own words: shown INSIDE a translated line
onMounted(async () => {
try {
const r = await fetch(`${assetsBase.value}/tables/key-cities.json`)
@@ -55,11 +57,8 @@ const named = computed(() => rows.value.filter((r) => r.named))
// Default to the NAMED population centres: a first screen of 1,369 unnamed
// single-group clusters would read as broken rather than as honest.
const filter = ref('named')
-const FILTERS = [
- { id: 'named', label: 'Named population centres' },
- { id: 'multi', label: 'Multi-group clusters' },
- { id: 'all', label: 'All locations' },
-]
+// Labels are keyCities.list.filters. (the template translates them).
+const FILTERS = [{ id: 'named' }, { id: 'multi' }, { id: 'all' }]
const shown = computed(() => {
if (filter.value === 'named') return named.value
@@ -87,6 +86,12 @@ const collapsed = ref({})
const isShut = (n) => !!collapsed.value[n]
const toggle = (n) => { collapsed.value = { ...collapsed.value, [n]: !collapsed.value[n] } }
const fmt = (n) => (Number(n) || 0).toLocaleString()
+const { t } = useI18n()
+// ONE key for the caveat's sentence: the count and the gazetteer's name go in as values, the name as
+// its own left-to-right island (it is a proper name, never translated), so each language orders it.
+const caveatRow = computed(() => h('span', sentenceParts(
+ t('keyCities.list.caveat.row', keep('n', 'source')),
+ { n: fmt(named.value.length), source: ltr('Nominatim/OpenStreetMap', true) })))
useShadowStyles(
'.kc{position:absolute;inset:0;overflow-y:auto;padding:20px 18px 44px;background:#f6f7f9;color:#0f1419;' +
@@ -139,7 +144,7 @@ const band = (a) => a === 'yes' ? 'ok' : a === 'partial' ? 'mid' : a === 'no' ?
{{ $t('keyCities.list.intro', { named: fmt(named.length), clusters: fmt(rows.length) }) }}
- What a row is. A cluster of people-group centroids within ~25km — not a municipal
- boundary. Pins mark where a people group is centred, not a settlement, so clusters are never
- named by inference: the {{ fmt(named.length) }} multi-group centres were resolved by
- Nominatim/OpenStreetMap (ODbL, cited per row) and the rest stay unnamed rather than guessed.
+ {{ $t('keyCities.list.caveat.rowLead') }}
- Internet figures are NATIONAL, shown as context. Per-city connectivity is not publicly
- available at this scale, and a national figure must not be read as a city measurement.
+ {{ $t('keyCities.map.national') }} {{ $t('keyCities.list.caveat.internet') }}
- This page carries people-group locations and connectivity only — no church, believer or
- meeting-point data, by design.
+ {{ $t('keyCities.list.caveat.scope') }}
diff --git a/plugin-designs/key-cities/cities-map.vue b/plugin-designs/key-cities/cities-map.vue
index 4eac00f21..d8221d40d 100644
--- a/plugin-designs/key-cities/cities-map.vue
+++ b/plugin-designs/key-cities/cities-map.vue
@@ -66,10 +66,11 @@ const selected = ref(null)
const err = ref(null)
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
+// Labels are keyCities.map.bands. (the legend translates them).
const BANDS = [
- { id: 'named', label: 'named centre', color: '#0b7a5b' },
- { id: 'multi', label: 'multi-group', color: '#e07a2b' },
- { id: 'single', label: 'single group', color: '#9aa2ad' },
+ { id: 'named', color: '#0b7a5b' },
+ { id: 'multi', color: '#e07a2b' },
+ { id: 'single', color: '#9aa2ad' },
]
const bandColor = (b) => (BANDS.find((x) => x.id === b) || {}).color || '#9aa2ad'
diff --git a/plugin-designs/key-cities/city-poster-wall.vue b/plugin-designs/key-cities/city-poster-wall.vue
index 4c028e002..e14c9c89f 100644
--- a/plugin-designs/key-cities/city-poster-wall.vue
+++ b/plugin-designs/key-cities/city-poster-wall.vue
@@ -19,6 +19,7 @@
* Partial and correct beats complete and wrong. The wall shows only what exists.
*/
import { computed, inject, ref, onMounted } from 'vue'
+import { useI18n } from 'vue-i18n'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
@@ -46,12 +47,9 @@ const items = computed(() => rows.value
bbox: null,
})))
-const groupBy = (c) => c.region || 'Other'
+const { t } = useI18n()
+const groupBy = (c) => c.region || t('keyCities.posters.otherRegion')
const collapsedBy = (sec) => sec.total < 20
-
-const intro =
- 'A printable sheet for each population centre where unengaged people groups live — ' +
- 'named from a cited gazetteer, never inferred.'
@@ -63,7 +61,7 @@ const intro =
event-name="doxa-key-cities:show-on-map"
:heading="$t('keyCities.posters.heading')"
:tag="$t('keyCities.posters.tag')"
- :intro="intro"
+ :intro="$t('keyCities.posters.intro')"
unit="UPG"
:item-noun="$t('keyCities.posters.itemNoun')"
:item-noun-plural="$t('keyCities.posters.itemNounPlural')"
diff --git a/plugin-designs/key-cities/data-tables.vue b/plugin-designs/key-cities/data-tables.vue
index df905c2b6..3e608f6e8 100644
--- a/plugin-designs/key-cities/data-tables.vue
+++ b/plugin-designs/key-cities/data-tables.vue
@@ -28,14 +28,15 @@ const profileConfig = inject('profileConfig', ref(null))
const isDark = computed(() => profileConfig.value?.isDark === true)
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
+// Tab labels are keyCities.tables.tabs. (the template translates them).
const TABS = [
- { id: 'cities', label: 'Key Cities', file: 'key-cities' },
+ { id: 'cities', file: 'key-cities' },
// PER-CITY languages: 30 clusters have a bridge language spoken RIGHT THERE
// that already carries a resource while the group's own language does not.
- { id: 'langcity', label: 'Languages Here', file: 'languages-per-city' },
- { id: 'languages', label: 'By Country', file: 'languages-by-country' },
- { id: 'operators', label: 'ISPs & Carriers', file: 'network-operators' },
- { id: 'messaging', label: 'Messaging Apps', file: 'messaging-channels' },
+ { id: 'langcity', file: 'languages-per-city' },
+ { id: 'languages', file: 'languages-by-country' },
+ { id: 'operators', file: 'network-operators' },
+ { id: 'messaging', file: 'messaging-channels' },
// Field-access/visa data is PRIVATE (kept outside the repo — release governance):
// publishing it could accelerate access closures. Tab removed from the
// public build; the dataset stays available for internal analysis.
@@ -98,7 +99,7 @@ useShadowStyles(
-