Merge main into go-int/work
This commit is contained in:
commit
cfae5f1adb
143 changed files with 3375 additions and 1406 deletions
38
data/boundaries/gb-IND-ADM1-full-web.geojson
Normal file
38
data/boundaries/gb-IND-ADM1-full-web.geojson
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/poster/slots/TitleSlot.vue'`
|
||||
- **QaCorner** — QaCorner.vue — the embeddable QA corner widget (bottom-right of any map).
|
||||
`import … from '@map/components/qa/QaCorner.vue'`
|
||||
- **RegionSectionCard** — RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
|
||||
`import … from '@map/components/regions/RegionSectionCard.vue'`
|
||||
- **FilterDropdown** — FilterDropdown.vue — SHARED (@map/components/search/FilterDropdown.vue).
|
||||
`import … from '@map/components/search/FilterDropdown.vue'`
|
||||
- **SearchBar** — SearchBar.vue — SHARED (@map/components/search/SearchBar.vue).
|
||||
|
|
|
|||
64
internal/check-i18n-stopped-scope.mjs
Normal file
64
internal/check-i18n-stopped-scope.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-i18n-stopped-scope.mjs — createAppI18n() must succeed while a STOPPED effect scope is current.
|
||||
*
|
||||
* WHY (P0, the Driver's console, 2026-09-27): in Plugin Studio's gallery every tab after the first threw
|
||||
* "SyntaxError: Unexpected error" from createI18n inside createAppI18n. vue-i18n's createGlobal() runs in
|
||||
* scope.run() and throws UNEXPECTED_ERROR when that returns undefined; Vue 3.5 creates a new EffectScope
|
||||
* INACTIVE when the currently active one is stopped; and a custom element's connectedCallback ->
|
||||
* configureApp -> createAppI18n can run while the released tab's stopped scope is still current.
|
||||
*
|
||||
* The CONTROL comes first: a bare createI18n() inside the same stopped scope must THROW, or this harness
|
||||
* does not reproduce the failure and a green result would prove nothing. Then createAppI18n() inside it
|
||||
* must succeed, translate, and keep reacting to a locale change.
|
||||
*/
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
let bad = 0
|
||||
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + got}`) }
|
||||
|
||||
const { createServer } = await import('vite')
|
||||
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
|
||||
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
|
||||
try {
|
||||
globalThis.__INTLIFY_JIT_COMPILATION__ = true
|
||||
const { effectScope } = await import('vue')
|
||||
const { createI18n } = await import('vue-i18n')
|
||||
const mod = await server.ssrLoadModule('/library/i18n/index.js')
|
||||
// Set AFTER vue loads (runtime-dom probes `document` at import). These are the two page facts
|
||||
// createAppI18n reads, the URL and <html lang>, as a plain English page would give them.
|
||||
globalThis.window ??= { location: { search: '' } }
|
||||
globalThis.document ??= { documentElement: { lang: 'en', dir: '' } }
|
||||
|
||||
// A scope that ran, was stopped, and is still the current one — the released tab's.
|
||||
const stopped = effectScope()
|
||||
stopped.run(() => {})
|
||||
stopped.stop()
|
||||
const inStopped = (fn) => { stopped.on(); try { return fn() } finally { stopped.off() } }
|
||||
|
||||
let controlThrew = false
|
||||
try { inStopped(() => createI18n({ legacy: false, locale: 'en', messages: { en: { a: 'b' } } })) } catch { controlThrew = true }
|
||||
check('CONTROL: a bare createI18n() inside a stopped scope throws (the harness reproduces the bug)', controlThrew, 'it did not throw')
|
||||
|
||||
let i18n = null, err = null
|
||||
try { i18n = inStopped(() => mod.createAppI18n()) } catch (e) { err = e }
|
||||
check('createAppI18n() succeeds inside a stopped scope', !!i18n && !err, err && String(err))
|
||||
if (i18n) {
|
||||
check('and it translates', i18n.global.t('buttons.close') !== 'buttons.close', i18n.global.t('buttons.close'))
|
||||
i18n.global.locale.value = 'ar'
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
check('and its locale watcher is live (the static reader follows a runtime switch)', mod.getRuntimeLocale() === 'ar', mod.getRuntimeLocale())
|
||||
// The bundle entries that still provide appI18n themselves after app.use(i18n) must not make Vue warn.
|
||||
const { createApp } = await import('vue')
|
||||
const warns = []; const warn = console.warn; console.warn = (...a) => warns.push(a.join(' '))
|
||||
try { const app = createApp({}); app.use(i18n); app.provide('appI18n', i18n) } finally { console.warn = warn }
|
||||
check('a bundle providing appI18n again after app.use(i18n) raises no Vue warning', !warns.some((w) => /appI18n/.test(w)), warns.join(' | '))
|
||||
}
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
|
||||
if (bad) { console.error(`\nI18N-STOPPED-SCOPE: FAIL — ${bad} assertion(s)`); process.exit(1) }
|
||||
console.log('\nI18N-STOPPED-SCOPE: PASS — the app i18n is created live even while a stopped effect scope is current.')
|
||||
|
|
@ -63,12 +63,36 @@ const PATTERN = new RegExp(
|
|||
function scan(text) {
|
||||
const hits = []
|
||||
const lines = text.split('\n')
|
||||
// ⚠ BLOCK-COMMENT STATE, not a per-line prefix test. The first version asked only whether a line
|
||||
// STARTS with //, * or /*, and that is not what a comment is: it misses every CONTINUATION line of a
|
||||
// /* … */ block whose text happens to begin with something else.
|
||||
//
|
||||
// IT COST A FALSE ACCUSATION, 2026-09-27, and the accusation was aimed at the correct fix. gospel
|
||||
// fixed this defect in resources-map.vue — `const gl = engineGlobal(grProvider)`, no `||` — and
|
||||
// documented what they removed in a /* */ block whose second line begins with a BACKTICK:
|
||||
// `engineGlobal(grProvider) || window.mapboxgl`. engineGlobal already defaults to mapbox, so
|
||||
// The prefix test saw no comment marker, matched the quoted pattern, and reported gospel as the last
|
||||
// remaining offender at main 9df086e5. I relayed that to dx-shepherd as fact and it was wrong: the
|
||||
// line number had also moved three times (1490 -> 1505 -> 1509) purely because their file grew, which
|
||||
// is what made a stale card look like a live defect.
|
||||
// ★ A GATE THAT FAILS ON THE EXPLANATION OF A FIX IS WORSE THAN NO GATE. It tells the one person who
|
||||
// did the right thing that they did the wrong thing, and the cheapest way to make it green is to
|
||||
// DELETE the explanation — so the gate actively destroys the reasoning it should protect. This file's
|
||||
// own header quotes the pattern for the same reason, which is why the skip existed at all; it was
|
||||
// just too shallow to cover the form another agent actually used.
|
||||
let inBlock = false
|
||||
lines.forEach((line, i) => {
|
||||
// Skip comment lines: this file's own documentation quotes the bad pattern, and so does every fix
|
||||
// that explains what it removed. A gate that fails on its own explanation teaches people to delete
|
||||
// the explanation.
|
||||
const t = line.trim()
|
||||
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return
|
||||
const wasInBlock = inBlock
|
||||
// Track /* and */ on this line so the NEXT line knows where it stands. Deliberately simple: a
|
||||
// block marker inside a string literal would fool it, and the alternative is a JS parser for a
|
||||
// one-expression grep. The failure mode of over-skipping is a missed offender, which the
|
||||
// --self-test guards against by keeping real forms in the MUST-MATCH list.
|
||||
const opens = (line.match(/\/\*/g) || []).length
|
||||
const closes = (line.match(/\*\//g) || []).length
|
||||
if (opens > closes) inBlock = true
|
||||
else if (closes > opens) inBlock = false
|
||||
if (wasInBlock || t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return
|
||||
PATTERN.lastIndex = 0
|
||||
let m
|
||||
while ((m = PATTERN.exec(line)) !== null) hits.push({ line: i + 1, engine: m[1], text: t.slice(0, 110) })
|
||||
|
|
@ -90,6 +114,21 @@ if (SELF_TEST) {
|
|||
'const gl = engineGlobal(p) || null',
|
||||
'const token = mapboxToken?.value || profileConfig?.value?.tk || \'\'',
|
||||
]
|
||||
// ★ MULTI-LINE CASES, because the single-line list above cannot express the bug that actually
|
||||
// happened: a CONTINUATION line of a /* */ block. Each entry is scanned as one text so the
|
||||
// block-comment state machine is what is under test, not the prefix check.
|
||||
const shouldNotMultiline = [
|
||||
// gospel's real fix at resources-map.vue, verbatim in shape — the line that was falsely accused
|
||||
// begins with a BACKTICK, so no prefix test could ever have skipped it.
|
||||
[` /* ⚠ NEVER SWITCH ENGINES SILENTLY. This used to read
|
||||
\`engineGlobal(grProvider) || window.mapboxgl\`. engineGlobal already defaults to mapbox, so
|
||||
the fallback could only ever fire for provider 'maplibre'. */
|
||||
const gl = engineGlobal(grProvider)`, 'a /* */ block quoting the removed fallback, then the correct fix'],
|
||||
// and the inverse: a real offender AFTER a block comment closes must still be caught, or the fix
|
||||
// above would have traded a false positive for a false negative.
|
||||
[` /* explanation that mentions engineGlobal(p) || window.mapboxgl and then ends */
|
||||
const gl = engineGlobal(p) || window.mapboxgl`, null],
|
||||
]
|
||||
let bad = 0
|
||||
for (const s of should) {
|
||||
const n = scan(s).length
|
||||
|
|
@ -101,6 +140,17 @@ if (SELF_TEST) {
|
|||
console.log(` ${n ? 'FALSE+ ' : 'ignored '} ${s.trim().slice(0, 62)}`)
|
||||
if (n) bad++
|
||||
}
|
||||
// The multi-line pairs: a label means "must be IGNORED and here is why", null means "must MATCH".
|
||||
// Both directions are asserted together on purpose — fixing the false positive by skipping more is
|
||||
// only correct if a real offender after the block still fails.
|
||||
for (const [text, why] of shouldNotMultiline) {
|
||||
const n = scan(text).length
|
||||
const wantHit = why === null
|
||||
const ok = wantHit ? n > 0 : n === 0
|
||||
if (!ok) bad++
|
||||
console.log(` ${ok ? (wantHit ? 'MATCHED ' : 'ignored ') : (wantHit ? 'MISSED ' : 'FALSE+ ')} `
|
||||
+ `[multi-line] ${wantHit ? 'a real offender AFTER a closed block comment' : why}`)
|
||||
}
|
||||
if (bad) {
|
||||
console.error(`\nNO-SILENT-ENGINE-SUBSTITUTION --self-test: FAIL — ${bad} case(s) wrong.`)
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
275
library/components/regions/RegionSectionCard.vue
Normal file
275
library/components/regions/RegionSectionCard.vue
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<script setup>
|
||||
/**
|
||||
* RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
|
||||
* and a footer link. The DX-life /regions card, made reusable.
|
||||
*
|
||||
* WHY THIS IS IN library/. The Driver, verbatim: "let's say we want to reuse that on future plugins.
|
||||
* We make it a reusable component, parameterized — you can set the region titles wherever you want
|
||||
* them to be, fill in the countries to whatever you want them to be." The design is the point as much
|
||||
* as the markup: the 156 lines of CSS below are lifted UNCHANGED from the regions plugin, so a caller
|
||||
* gets the look, not a lookalike.
|
||||
*
|
||||
* ★ IT TAKES DATA, NOT AN API. The plugin version fetched its own regions and owned the layout, which
|
||||
* is what made it unusable anywhere else. This takes `title`, `summary` and `countries` and emits what
|
||||
* was clicked. Nothing here knows where regions come from.
|
||||
*
|
||||
* ★ THE CHEVRON IS INLINE ON PURPOSE, and this is the one thing a mover has to get right. The plugin
|
||||
* declared <symbol id="chevron-up"> ONCE in its own template and every card referenced it with
|
||||
* <use href="#chevron-up">. A shadow-root id is scoped to that root, so the same card mounted anywhere
|
||||
* else would reference a symbol that does not exist — and an unresolved <use> renders NOTHING, with no
|
||||
* error. The chevron would simply be missing. The plugin's own comment warns about exactly this class
|
||||
* for external hrefs ("would 404 SILENTLY and the chevron would simply vanish"); a cross-root id
|
||||
* reference fails the same way. So the path travels with the component.
|
||||
*
|
||||
* ★ RTL NEEDS AN ANCESTOR CARRYING dir, and that is a REQUIREMENT ON THE CONSUMER. The rules below
|
||||
* include [dir="rtl"] .region-card__icon, ported because a chevron meaning "forward" must mirror in
|
||||
* Arabic while a disclosure caret meaning "open/closed" must not — and the rtl --open rule is
|
||||
* load-bearing, since [dir="rtl"] .region-card__icon is specificity 0-2-0 and would otherwise beat the
|
||||
* 0-1-0 --open modifier and freeze the caret. The regions plugin satisfies this because .rl-root
|
||||
* carries :dir itself. A new consumer must set dir on an ancestor inside its own shadow root, or the
|
||||
* mirroring silently will not happen.
|
||||
*
|
||||
* ★ EXTRA CONTENT IS A SLOT, NOT A PROP. The regions plugin shows a country-poster wall under each
|
||||
* card, configured with fourteen PosterWall props. Baking that in would have dragged one plugin's
|
||||
* feature into every consumer's API. The `extra` slot keeps it where it belongs.
|
||||
*/
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** The region's display name. Already localized by the caller. */
|
||||
title: { type: String, default: '' },
|
||||
/** The line under the title, e.g. "12 countries · 480 groups". Caller-formatted. */
|
||||
summary: { type: String, default: '' },
|
||||
/** [{ name, count, slug }] — `count` is optional and rendered in parentheses when present. */
|
||||
countries: { type: Array, default: () => [] },
|
||||
/** The footer link's text, e.g. "View the region dashboard". Omit it to hide the footer. */
|
||||
footerLabel: { type: String, default: '' },
|
||||
/**
|
||||
* OPTIONAL DISCLOSURE ROW, and the card owns it deliberately. The regions plugin needed a
|
||||
* "View country posters" toggle under each card. The first cut put that row in the consumer's slot
|
||||
* — which immediately meant copying this component's footer-link look and its rotating caret back
|
||||
* into the plugin, i.e. re-creating the duplication this extraction exists to remove. A disclosure
|
||||
* inside a card is a CARD feature; only the revealed CONTENT belongs to the consumer.
|
||||
* Omit the label to hide the row entirely.
|
||||
*/
|
||||
disclosureLabel: { type: String, default: '' },
|
||||
disclosureOpen: { type: Boolean, default: false },
|
||||
/** id for aria-controls, so the button and the revealed region are really associated. */
|
||||
disclosureId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'select-country', 'toggle-disclosure'])
|
||||
|
||||
useShadowStyles(
|
||||
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
|
||||
// the dark header band supplies its own edge, unlike our old grey-outlined card.
|
||||
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
|
||||
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
|
||||
// is KEPT as a clamp (var(--spacing-md) / var(--spacing-2xl) are the resolved ends), not frozen at its desktop maximum.
|
||||
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
|
||||
'.region-card__header{ display:flex; flex-direction:column; gap:var(--spacing-2xs);' +
|
||||
' padding:var(--spacing-md) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl));' +
|
||||
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
|
||||
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(var(--font-size-xl),_,var(--font-size-3xl)).
|
||||
// A phone gets the var(--font-size-xl) FLOOR, as theirs does; it only reaches var(--font-size-3xl) past ~1192px of width.
|
||||
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
|
||||
// face itself carries the boldness), all-caps to match the source screenshot exactly.
|
||||
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
|
||||
// not served with CORS -- cross-origin @font-face silently fails without it, so it cannot be
|
||||
// loaded here without shipping a font asset this repo doesn't own. Approximated with the
|
||||
// standard condensed-display-face fallback stack (bold weight + font-stretch:condensed + tight
|
||||
// tracking), since Bebas Neue's glyphs read as heavy/compressed even at its nominal "400" weight.
|
||||
// getComputedStyle() on the live card title reads weight 400 / line-height 1.0 (47.68px on a
|
||||
// 47.69px font-size) / letter-spacing normal / text-transform none -- Bebas Neue's own glyphs
|
||||
// are what read as bold+condensed+caps-like, DX-life applies NO transform on top of the font.
|
||||
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
|
||||
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
|
||||
// exactly rather than compounding extra "boldness" the source doesn't have.
|
||||
'.region-card__title{ margin:0; font-size:clamp(var(--font-size-xl), 4cqw, var(--font-size-3xl)); line-height:1;' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; letter-spacing:normal; }' +
|
||||
// Their link is an <a class="region-card__link | with-icon">, so it carries their `a` rule's
|
||||
// font-weight: var(--font-weight-medium) = 500 (_globals.scss:132). Ours is a <button>
|
||||
// because this element has no router, and `font:inherit` gave it 400 — MEASURED against
|
||||
// their live /regions. Same class of miss as the dashboard back-link.
|
||||
// The icon spacing is theirs too: .with-icon is inline-flex with align-items:baseline and NO
|
||||
// gap (composition/_icon.scss:12), and the 1rem separation comes from
|
||||
// `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px at their 10px root. We had
|
||||
// the same 10px via `gap`, which looks identical but means their rule would not apply, so
|
||||
// the mechanism is now theirs: margin on the icon, gap left alone.
|
||||
// ★ text-align:START, not left. Theirs is a NuxtLink, an inline <a> that shrink-wraps to its
|
||||
// text; ours must be a <button> because this element changes the embed's subject in place instead
|
||||
// of navigating a route. A button's UA default is text-align:center, so the port overrode it -- but
|
||||
// it overrode it with the PHYSICAL `left`, which is identical to `start` in English and its
|
||||
// opposite in Arabic. MEASURED on live /ar/regions vs ours at dir=rtl: same 246.9px row and the
|
||||
// same 102.7px of text, their label flush to the row's RIGHT edge (gap 0px, the RTL reading start)
|
||||
// and ours flush LEFT -- every label stranded 144.2px on the wrong side. Invisible in English,
|
||||
// which is exactly why it survived a 37-property card diff and an 11-property bullet diff.
|
||||
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:start;' +
|
||||
' cursor:pointer; color:inherit; font:inherit; font-weight:500;' +
|
||||
' display:inline-flex; align-items:baseline; }' +
|
||||
'.region-card__link:hover{ color:#92b195; }' +
|
||||
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
|
||||
// .region-card__icon: DX-life's own <svg class="icon | rotate-90 right"><use href="arrow-
|
||||
// chevron.svg#chevron-up"></use></svg> -- a REAL chevron path (viewBox 0 0 489.67 289.877),
|
||||
// not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour,
|
||||
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
|
||||
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
|
||||
// ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on
|
||||
// our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the
|
||||
// element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and
|
||||
// their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours
|
||||
// inherits whatever the host sets: 16px by default, so this gap rendered 16px against their
|
||||
// 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by
|
||||
// accident. Pinned to the value their root produces, like every other token here.
|
||||
// No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and
|
||||
// alignItems — no margin — so the icon's own spacing was never a measured property. It is now.
|
||||
// (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.)
|
||||
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' +
|
||||
' margin-inline-start:10px; }' +
|
||||
// .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band.
|
||||
'.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' +
|
||||
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44:
|
||||
// h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } }
|
||||
// and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC
|
||||
// GLYPHS, so a Russian heading in it leaves the family entirely and lands on whatever condensed
|
||||
// system face the browser picks. We ship a `ru` locale and had neither the rule nor the font.
|
||||
// ONLY the headings, exactly as theirs — their .region-stat__value is a <p> and keeps Bebas even in
|
||||
// Russian, which is their behaviour and so is ours.
|
||||
// BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`,
|
||||
// and a host page embedding this element may set either. A selector that matched only one would
|
||||
// work on their site and fail on ours, or the reverse, depending on who set the attribute.
|
||||
'[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,' +
|
||||
'[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' letter-spacing:-0.02em; }' +
|
||||
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl)
|
||||
'.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' +
|
||||
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
|
||||
// (10px / var(--spacing-xl)). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
|
||||
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
|
||||
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
|
||||
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
|
||||
// bullet has somewhere to sit without stealing width from the text column.
|
||||
// padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces
|
||||
// put the indent on the wrong side in Arabic, where the bullets hang off the right edge.
|
||||
'.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' +
|
||||
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' +
|
||||
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
|
||||
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
|
||||
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
|
||||
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
|
||||
// Their country entries are bare <NuxtLink>s inside .countries-grid li — no class of their
|
||||
// own — so they take their `a` rule: color var(--color-brand-light) = $color-green-400 =
|
||||
// #4e594f, font-weight medium. MEASURED on their live /regions: rgb(78, 89, 79). Ours used
|
||||
// var(--dx-text), which resolves to rgb(42, 51, 42) — the body ink, not their link ink.
|
||||
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
|
||||
' text-align:start; cursor:pointer; color:#4e594f; font:inherit; font-size:var(--font-size-md);' +
|
||||
' font-weight:500; width:100%; position:relative; }' +
|
||||
// inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the
|
||||
// RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured
|
||||
// at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang.
|
||||
// ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list
|
||||
// marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start
|
||||
// edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their
|
||||
// .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list
|
||||
// bullets (global ul padding + ::before dot)").
|
||||
// I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong
|
||||
// in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the
|
||||
// image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their
|
||||
// negative margin. Not a near-miss — a different bullet.
|
||||
// The rule is a double-quoted JS string because the data URI contains single quotes.
|
||||
".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" +
|
||||
" line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" +
|
||||
" background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" +
|
||||
// Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this
|
||||
// CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in.
|
||||
'[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' +
|
||||
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
|
||||
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
|
||||
'.country-link__count{ color:var(--dx-muted); }' +
|
||||
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (var(--spacing-lg)),
|
||||
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~var(--font-size-md) at
|
||||
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
|
||||
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
|
||||
// The toggle row and the wall it opens. The caret rotates rather than swapping glyphs,
|
||||
// which is what makes open/closed legible at a glance.
|
||||
'.region-card__posters-toggle{ margin-top:0; }' +
|
||||
'.region-card__icon--open{ transform:rotate(180deg); }' +
|
||||
// RTL MIRRORING, ported from their utilities/_shapes.scss:
|
||||
// .rotate-90 { transform:rotate(90deg); [dir="rtl"] & { transform:rotate(-90deg); } }
|
||||
// Their .rotate-180 has NO rtl variant, and that distinction is the whole rule: a chevron that
|
||||
// means "forward" must mirror, a disclosure caret that means "open/closed" must not. MEASURED
|
||||
// on live doxa.life: .region-card__link svg is matrix(0,1,-1,0,0,0) at /regions and
|
||||
// matrix(0,-1,1,0,0,0) at /ar/regions — ours stayed at +90deg in Arabic, so the chevron sat on
|
||||
// the correct side pointing the WRONG WAY. Found by looking at the Arabic screenshot.
|
||||
// The selector works inside our shadow root because .rl-root carries :dir itself.
|
||||
'[dir="rtl"] .region-card__icon{ transform:rotate(-90deg); }' +
|
||||
// ...and this one is load-bearing: [dir="rtl"] .region-card__icon is specificity 0-2-0, which
|
||||
// would otherwise beat the 0-1-0 --open modifier and freeze the poster caret in Arabic.
|
||||
'[dir="rtl"] .region-card__icon--open{ transform:rotate(180deg); }' +
|
||||
'.region-card__posters{ margin-top:13.3px; }' +
|
||||
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:var(--spacing-lg);' +
|
||||
' padding:0 clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-lg); }' +
|
||||
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
|
||||
' cursor:pointer; font:inherit; font-size:var(--font-size-md); font-weight:500; color:var(--dx-link);' +
|
||||
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
|
||||
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
|
||||
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',
|
||||
'regions-list'
|
||||
, 'region-section-card')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="region-card">
|
||||
<div class="region-card__header">
|
||||
<h2 class="region-card__title">
|
||||
<button type="button" class="region-card__link" @click="emit('select')">
|
||||
{{ title }}
|
||||
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
|
||||
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</h2>
|
||||
<p v-if="summary" class="region-card__summary">{{ summary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="region-card__body">
|
||||
<ul class="countries-grid">
|
||||
<li v-for="c in countries" :key="c.slug || c.name">
|
||||
<button type="button" class="country-link" @click="emit('select-country', c)">
|
||||
{{ c.name }}<span v-if="c.count != null" class="country-link__count"> ({{ c.count }})</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p v-if="footerLabel" class="region-card__footer">
|
||||
<button type="button" class="region-card__footer-link" @click="emit('select')">
|
||||
{{ footerLabel }}
|
||||
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
|
||||
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p v-if="disclosureLabel" class="region-card__footer region-card__posters-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="region-card__footer-link"
|
||||
:aria-expanded="disclosureOpen ? 'true' : 'false'"
|
||||
:aria-controls="disclosureId || undefined"
|
||||
@click="emit('toggle-disclosure')"
|
||||
>
|
||||
{{ disclosureLabel }}
|
||||
<svg class="region-card__icon" :class="{ 'region-card__icon--open': disclosureOpen }" viewBox="0 0 489.67 289.877" aria-hidden="true">
|
||||
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<!-- Only the revealed CONTENT is the consumer's. The regions plugin puts its PosterWall here. -->
|
||||
<div v-if="disclosureOpen" :id="disclosureId || undefined" class="region-card__posters">
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
* See documentation/feedback.md → Section A for the design rationale.
|
||||
*/
|
||||
|
||||
import { watch } from 'vue'
|
||||
import { watch, effectScope } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
// Eager glob — all locale subfolders inlined at build time
|
||||
|
|
@ -154,7 +154,14 @@ export function createAppI18n() {
|
|||
document.documentElement.dir = 'rtl'
|
||||
}
|
||||
} catch { /* no document (SSR/tests) — nothing to reflect */ }
|
||||
const i18n = createI18n({
|
||||
// ★ A DETACHED EFFECT SCOPE (P0, the Driver's console, 2026-09-27). vue-i18n's createGlobal() runs
|
||||
// inside scope.run() and throws UNEXPECTED_ERROR ("SyntaxError: Unexpected error") when that returns
|
||||
// undefined; Vue 3.5 creates a new EffectScope INACTIVE when the currently active scope is STOPPED.
|
||||
// A custom element's connectedCallback -> configureApp -> here can run while a released tab's stopped
|
||||
// scope is still current (Plugin Studio's gallery, every tab after the first). effectScope(true) never
|
||||
// inherits the ambient scope, so this instance and its locale watcher are always created live.
|
||||
const i18nScope = effectScope(true)
|
||||
const i18n = i18nScope.run(() => createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale,
|
||||
|
|
@ -162,7 +169,7 @@ export function createAppI18n() {
|
|||
messages,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
})
|
||||
}))
|
||||
|
||||
// ★ INSTALLING i18n ALSO PROVIDES IT, so a bundle CANNOT forget. This is the durable half of a
|
||||
// bug that is invisible by construction.
|
||||
|
|
@ -195,12 +202,17 @@ export function createAppI18n() {
|
|||
// Watching the instance's own locale ref is the single choke point: ProfileLoader, the SPA's
|
||||
// picker and the studio's picker all move THIS ref, so none of them has to remember to also tell
|
||||
// the static reader.
|
||||
watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true })
|
||||
i18nScope.run(() => watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true }))
|
||||
|
||||
const install = i18n.install.bind(i18n)
|
||||
i18n.install = (app, ...rest) => {
|
||||
const out = install(app, ...rest)
|
||||
app.provide('appI18n', i18n)
|
||||
// Several bundle entries still call app.provide('appI18n', i18n) themselves after app.use(i18n). The
|
||||
// value is the same instance, but Vue warns "App already provides property with key appI18n" on every
|
||||
// mount. A second provide of THIS instance under that key is made a no-op; anything else passes through.
|
||||
const provide = app.provide.bind(app)
|
||||
app.provide = (key, value) => (key === 'appI18n' && value === i18n ? app : provide(key, value))
|
||||
return out
|
||||
}
|
||||
return i18n
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@
|
|||
"t_deaf": "فجوة الصم",
|
||||
"t_resources": "الموارد حسب الشعب",
|
||||
"t_languages": "اللغات حسب البلد",
|
||||
"t_growth": "الوصول مقابل نمو السكان"
|
||||
"t_growth": "الوصول مقابل نمو السكان",
|
||||
"t_bridgereach": "لغة واحدة، كم شعبًا؟"
|
||||
},
|
||||
"pages": {
|
||||
"listTitle": "موارد الإنجيل حسب اللغة",
|
||||
|
|
|
|||
|
|
@ -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": "اختر هدفًا أحمر على الخريطة —",
|
||||
|
|
|
|||
|
|
@ -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} لغة"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 —",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 —",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 —",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 —",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@
|
|||
"t_deaf": "बधिर कमी",
|
||||
"t_resources": "जनसमूह अनुसार संसाधन",
|
||||
"t_languages": "देश अनुसार भाषाएँ",
|
||||
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि"
|
||||
"t_growth": "पहुँच बनाम जनसंख्या वृद्धि",
|
||||
"t_bridgereach": "एक भाषा, कितने जनसमूह"
|
||||
},
|
||||
"pages": {
|
||||
"listTitle": "भाषा अनुसार सुसमाचार संसाधन",
|
||||
|
|
|
|||
|
|
@ -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": "नक्शे पर एक लाल लक्ष्य चुनें —",
|
||||
|
|
|
|||
|
|
@ -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} भाषाओं में नाम"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 —",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 —",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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ă —",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@
|
|||
"t_deaf": "Пробел у глухих",
|
||||
"t_resources": "Ресурсы по народам",
|
||||
"t_languages": "Языки по странам",
|
||||
"t_growth": "Доступ и рост населения"
|
||||
"t_growth": "Доступ и рост населения",
|
||||
"t_bridgereach": "Один язык — сколько народов"
|
||||
},
|
||||
"pages": {
|
||||
"listTitle": "Ресурсы Евангелия по языкам",
|
||||
|
|
|
|||
|
|
@ -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": "Выберите красную цель на карте —",
|
||||
|
|
|
|||
|
|
@ -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} языках"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@
|
|||
"t_deaf": "聋人缺口",
|
||||
"t_resources": "按族群列出资源",
|
||||
"t_languages": "各国语言",
|
||||
"t_growth": "可及性与人口增长"
|
||||
"t_growth": "可及性与人口增长",
|
||||
"t_bridgereach": "一种语言,覆盖多少民族"
|
||||
},
|
||||
"pages": {
|
||||
"listTitle": "按语言列出的福音资源",
|
||||
|
|
|
|||
|
|
@ -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": "在地图上选择一个红色目标 —",
|
||||
|
|
|
|||
|
|
@ -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} 种语言中的名称"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
library/scripts/boundaries/build-web-boundaries.mjs
Normal file
64
library/scripts/boundaries/build-web-boundaries.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-web-boundaries.mjs — the INTERACTIVE-map copies of the boundary files: small enough to parse
|
||||
* fast, with every feature, every island and every property kept.
|
||||
*
|
||||
* node library/scripts/boundaries/build-web-boundaries.mjs
|
||||
*
|
||||
* WHY. rega measured ~3.6 MB of boundary GeoJSON (world-adm0 1.9 MB, gb-IND-ADM1 1.6 MB) costing
|
||||
* 5–6 s of MapLibre worker parsing before the first territory draws, on every page of every
|
||||
* partner site. A web map drawn at zoom 1–7 needs a fraction of those vertices. The FULL files stay
|
||||
* as they are: posters and print use them, and so does every build-time point-in-polygon.
|
||||
*
|
||||
* HOW (mapshaper, pinned below; fetched with npx, it is not a repo dependency):
|
||||
* -explode split each multi-part feature into its parts, so every island is its own shape
|
||||
* -simplify visvalingam weighted percentage=P keep-shapes
|
||||
* topology-preserving (shared borders stay shared); keep-shapes stops any shape
|
||||
* from vanishing, which, after the explode, means no island is ever dropped
|
||||
* (without the explode, keep-shapes guards only whole features: 572 parts -> 240)
|
||||
* -dissolve KEY copy-fields=... put each feature back together, same properties
|
||||
* precision=0.0001 ~11 m, far below a pixel at the zooms these files are drawn at
|
||||
*
|
||||
* VALIDATED, the run FAILS otherwise: the same feature count, the same keys, the same polygon-part
|
||||
* count (no dropped island), and the same property names and values on every feature.
|
||||
* The measured report (size, parts, area error, point-in-polygon drift) is in
|
||||
* _qa-cycle/03-deployment-cycle/REPORT-WEB-BOUNDARIES.md.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const MAPSHAPER = 'mapshaper@0.6.121'
|
||||
const UPG = 'plugin-designs/my-upg-100-list/data'
|
||||
const JOBS = [
|
||||
// src, out, dissolve key, the other properties, simplification percentage
|
||||
{ src: `${UPG}/adm0/world-adm0.geojson`, out: `${UPG}/adm0/world-adm0-web.geojson`, key: 'iso3', copy: ['shapeGroup', 'shapeName'], pct: '8%' },
|
||||
{ src: 'data/boundaries/gb-IND-ADM1-full.geojson', out: 'data/boundaries/gb-IND-ADM1-full-web.geojson', key: 'shapeName', copy: ['iso3', 'shapeGroup', 'shapeISO'], pct: '10%' },
|
||||
// UPG-100's curated partition: `listId` is load-bearing (zone-geometry.js), so it must survive.
|
||||
{ src: `${UPG}/gb-IND-ADM1.geojson`, out: `${UPG}/gb-IND-ADM1-web.geojson`, key: 'shapeName', copy: ['iso3', 'shapeGroup', 'listId'], pct: '10%' },
|
||||
// gb-CHN-ADM1.geojson is 56 KB (12 provinces): already web-sized, no copy made.
|
||||
]
|
||||
|
||||
const fail = (m) => { console.error('build-web-boundaries: FAIL — ' + m); process.exit(1) }
|
||||
const parts = (fc) => fc.features.reduce((t, f) => t + (f.geometry.type === 'Polygon' ? 1 : f.geometry.coordinates.length), 0)
|
||||
|
||||
for (const j of JOBS) {
|
||||
const src = join(ROOT, j.src), out = join(ROOT, j.out)
|
||||
execFileSync('npx', ['--yes', MAPSHAPER, src, '-explode',
|
||||
'-simplify', 'visvalingam', 'weighted', `percentage=${j.pct}`, 'keep-shapes',
|
||||
'-dissolve', j.key, `copy-fields=${j.copy.join(',')}`,
|
||||
'-o', out, 'format=geojson', 'precision=0.0001'], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
const a = JSON.parse(readFileSync(src, 'utf8')), b = JSON.parse(readFileSync(out, 'utf8'))
|
||||
if (a.features.length !== b.features.length) fail(`${j.out}: ${b.features.length} features, source has ${a.features.length}`)
|
||||
const byKey = new Map(b.features.map((f) => [f.properties[j.key], f]))
|
||||
for (const f of a.features) {
|
||||
const g = byKey.get(f.properties[j.key])
|
||||
if (!g) fail(`${j.out}: lost ${j.key}=${f.properties[j.key]}`)
|
||||
for (const [k, v] of Object.entries(f.properties)) if (g.properties[k] !== v) fail(`${j.out}: ${f.properties[j.key]}.${k} is ${JSON.stringify(g.properties[k])}, source ${JSON.stringify(v)}`)
|
||||
}
|
||||
if (parts(a) !== parts(b)) fail(`${j.out}: ${parts(b)} polygon parts, source has ${parts(a)} (an island was dropped)`)
|
||||
const kb = (p) => Math.round(statSync(p).size / 1024)
|
||||
console.log(`${j.out}: ${kb(src)} KB -> ${kb(out)} KB · ${b.features.length} features · ${parts(b)} parts · properties identical`)
|
||||
}
|
||||
|
|
@ -182,7 +182,9 @@
|
|||
"funnel_list_unreached": "Project 7000: unreached people groups",
|
||||
"funnel_list_frontier": "Project 4700: frontier people groups",
|
||||
"funnel_list_frontier-500k": "Project 700: frontier people groups of 500,000 or more",
|
||||
"funnel_list_unengaged": "Unengaged unreached people groups"
|
||||
"funnel_list_unengaged": "Unengaged unreached people groups",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} more"
|
||||
},
|
||||
"es": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
|
||||
|
|
@ -357,7 +359,9 @@
|
|||
"funnel_list_unreached": "Proyecto 7000: grupos étnicos no alcanzados",
|
||||
"funnel_list_frontier": "Proyecto 4700: grupos étnicos de frontera",
|
||||
"funnel_list_frontier-500k": "Proyecto 700: grupos étnicos de frontera de 500.000 o más",
|
||||
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros"
|
||||
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} más"
|
||||
},
|
||||
"fr": {
|
||||
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
|
||||
|
|
@ -532,7 +536,9 @@
|
|||
"funnel_list_unreached": "Projet 7000 : peuples non atteints",
|
||||
"funnel_list_frontier": "Projet 4700 : peuples frontières",
|
||||
"funnel_list_frontier-500k": "Projet 700 : peuples frontières de 500 000 personnes ou plus",
|
||||
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier"
|
||||
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} autres"
|
||||
},
|
||||
"pt": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
|
||||
|
|
@ -707,7 +713,9 @@
|
|||
"funnel_list_unreached": "Projeto 7000: grupos étnicos não alcançados",
|
||||
"funnel_list_frontier": "Projeto 4700: grupos étnicos de fronteira",
|
||||
"funnel_list_frontier-500k": "Projeto 700: grupos étnicos de fronteira de 500.000 ou mais",
|
||||
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros"
|
||||
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} mais"
|
||||
},
|
||||
"de": {
|
||||
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
|
||||
|
|
@ -882,7 +890,9 @@
|
|||
"funnel_list_unreached": "Projekt 7000: unerreichte Volksgruppen",
|
||||
"funnel_list_frontier": "Projekt 4700: Frontier-Volksgruppen",
|
||||
"funnel_list_frontier-500k": "Projekt 700: Frontier-Volksgruppen mit 500.000 oder mehr",
|
||||
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter"
|
||||
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} weitere"
|
||||
},
|
||||
"it": {
|
||||
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
|
||||
|
|
@ -1057,7 +1067,9 @@
|
|||
"funnel_list_unreached": "Progetto 7000: gruppi etnici non raggiunti",
|
||||
"funnel_list_frontier": "Progetto 4700: gruppi etnici di frontiera",
|
||||
"funnel_list_frontier-500k": "Progetto 700: gruppi etnici di frontiera di 500.000 o più",
|
||||
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai"
|
||||
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + altri {n}"
|
||||
},
|
||||
"ro": {
|
||||
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
|
||||
|
|
@ -1232,7 +1244,9 @@
|
|||
"funnel_list_unreached": "Proiectul 7000: grupuri etnice neatinse",
|
||||
"funnel_list_frontier": "Proiectul 4700: grupuri etnice de frontieră",
|
||||
"funnel_list_frontier-500k": "Proiectul 700: grupuri etnice de frontieră de 500.000 sau mai multe",
|
||||
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători"
|
||||
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + încă {n}"
|
||||
},
|
||||
"ru": {
|
||||
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
|
||||
|
|
@ -1407,7 +1421,9 @@
|
|||
"funnel_list_unreached": "Проект 7000: неохваченные народы",
|
||||
"funnel_list_frontier": "Проект 4700: народы-фронтир",
|
||||
"funnel_list_frontier-500k": "Проект 700: народы-фронтир численностью 500 000 и более",
|
||||
"funnel_list_unengaged": "Неохваченные народы без работников"
|
||||
"funnel_list_unengaged": "Неохваченные народы без работников",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + ещё {n}"
|
||||
},
|
||||
"ar": {
|
||||
"map_page_kicker": "شعوب غير مبلَّغة",
|
||||
|
|
@ -1582,7 +1598,9 @@
|
|||
"funnel_list_unreached": "مشروع 7000: المجموعات الشعبية غير المبلَّغة",
|
||||
"funnel_list_frontier": "مشروع 4700: المجموعات الشعبية الحدودية",
|
||||
"funnel_list_frontier-500k": "مشروع 700: المجموعات الشعبية الحدودية التي يبلغ عددها 500,000 أو أكثر",
|
||||
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين"
|
||||
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} أخرى"
|
||||
},
|
||||
"hi": {
|
||||
"map_page_kicker": "अनछुए जनसमूह",
|
||||
|
|
@ -1757,7 +1775,9 @@
|
|||
"funnel_list_unreached": "परियोजना 7000: अप्राप्त जनसमूह",
|
||||
"funnel_list_frontier": "परियोजना 4700: सीमांत जनसमूह",
|
||||
"funnel_list_frontier-500k": "परियोजना 700: 500,000 या अधिक के सीमांत जनसमूह",
|
||||
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं"
|
||||
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + {n} और"
|
||||
},
|
||||
"zh": {
|
||||
"map_page_kicker": "未得之民",
|
||||
|
|
@ -1932,6 +1952,8 @@
|
|||
"funnel_list_unreached": "7000 计划:未得之民族群",
|
||||
"funnel_list_frontier": "4700 计划:前沿族群",
|
||||
"funnel_list_frontier-500k": "700 计划:50 万人以上的前沿族群",
|
||||
"funnel_list_unengaged": "无人服事的未得之民族群"
|
||||
"funnel_list_unengaged": "无人服事的未得之民族群",
|
||||
"zone_join": "{a} + {b}",
|
||||
"zone_join_more": "{a} + 另外 {n} 个"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
library/scripts/research/app-kind.mjs
Normal file
26
library/scripts/research/app-kind.mjs
Normal 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';
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@
|
|||
"check:i18n-keys-exist": "node plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs",
|
||||
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
|
||||
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
|
||||
"check:i18n-stopped-scope": "node internal/check-i18n-stopped-scope.mjs",
|
||||
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
|
||||
"check:kit-options-reachable": "node plugin-designs/research-dashboard/scripts/check-kit-options-reachable.mjs",
|
||||
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
|
||||
|
|
@ -195,6 +196,7 @@
|
|||
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
|
||||
"check:settled": "node internal/settled-tree-gate.mjs",
|
||||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
|
||||
"check:regions-uses-library": "node plugin-designs/regions-dashboard/scripts/check-regions-uses-library.mjs",
|
||||
"check:religion-label-not-value": "node plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs",
|
||||
"check:share-policy": "node internal/check-share-policy.mjs",
|
||||
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs",
|
||||
|
|
@ -232,7 +234,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -339,6 +341,7 @@
|
|||
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
|
||||
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
|
||||
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
"check:i18n-stopped-scope": "createAppI18n() must succeed while a STOPPED effect scope is current: the Driver's P0 (Plugin Studio gallery, every tab after the first threw 'SyntaxError: Unexpected error' from createI18n, because Vue 3.5 makes a new scope inactive under a stopped one). A bare createI18n() in the same scope must throw first, or the harness proves nothing. Also asserts a bundle's second provide('appI18n') raises no warning.",
|
||||
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
|
||||
"check:kit-options-reachable": "every option a shared kit composable ACCEPTS must be honoured somewhere in its body. Written because I nearly shipped one: kit landing 3 gave useSelectionCamera a `coords` option so a dashboard whose rows spell their coordinate differently could say so, and it was accepted in the signature and never passed to selectionBounds - a caller could set it, type-check clean, build clean and watch it do nothing. That is the worst shape a missing feature takes, because it READS as supported and nobody files a bug against a knob they assume they misused; this tree already paid for it once with a defineProps on a component the custom element never binds. It matters more for the kit than for one dashboard: the whole promise of SPEC-DASHBOARD-KIT is that a config produces a working dashboard, and a parameter that is merely present turns that promise into a guess. Strips comments from BOTH the signature and the body before searching - the first version was satisfied by the comment beside the fixed call site, and the second read comment prose as option names. Not a type checker: it catches a completely unused option, which is the failure invisible from outside.",
|
||||
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",
|
||||
|
|
|
|||
|
|
@ -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 || []) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1114,7 +1114,7 @@ async function load() {
|
|||
// review caught it as "India seems to have no internet coverage". The fix was a
|
||||
// second file, world-adm0-plus.geojson; that file is gone and India is fetched
|
||||
// at ADM0 like every other country (data/source-boundaries.mjs).
|
||||
fetch(`${assetsBase.value}/boundaries/world-adm0.geojson`).then((r) => r.json()),
|
||||
fetch(`${assetsBase.value}/boundaries/world-adm0-web.geojson`).then((r) => r.json()),
|
||||
// 650KB and it is the point of the map, so it loads with the boundaries rather
|
||||
// than behind a tap. URLs are normalised into `codes` by ISO 639-3, so the 12,379
|
||||
// pins carry a code rather than 12,379 copies of the same jesusfilm.org link.
|
||||
|
|
@ -1270,7 +1270,7 @@ async function ensureProvinceTable() {
|
|||
return provinceRows.value
|
||||
}
|
||||
|
||||
const PROVINCE_FILE = { IND: 'gb-IND-ADM1-full.geojson', CHN: 'gb-CHN-ADM1.geojson' }
|
||||
const PROVINCE_FILE = { IND: 'gb-IND-ADM1-full-web.geojson', CHN: 'gb-CHN-ADM1.geojson' }
|
||||
|
||||
async function ensureProvinces(iso3) {
|
||||
const map = mapRef.value
|
||||
|
|
|
|||
164
plugin-designs/my-upg-100-list/data/adm0/world-adm0-web.geojson
vendored
Normal file
164
plugin-designs/my-upg-100-list/data/adm0/world-adm0-web.geojson
vendored
Normal file
File diff suppressed because one or more lines are too long
36
plugin-designs/my-upg-100-list/data/gb-IND-ADM1-web.geojson
vendored
Normal file
36
plugin-designs/my-upg-100-list/data/gb-IND-ADM1-web.geojson
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -85,6 +85,16 @@ function leafPopulation(row, records) {
|
|||
export function buildLegendNodes(clusters, opts = {}) {
|
||||
const deafCount = opts.deafCount ?? 123;
|
||||
const records = Array.isArray(opts.records) ? opts.records : null;
|
||||
// ★ OPTIONAL GENERATED TITLE (Driver, 2026-09-27). `opts.title(zone)` returns a localized title
|
||||
// composed from already-translated parts, or NULL for a zone whose title cannot be generated — see
|
||||
// data/zone-title.js. Passing nothing keeps every hand-written English label exactly as it was, which
|
||||
// is why this is an option rather than a rewrite: the legend, the poster and the packet adopt it
|
||||
// independently, and a caller that has no records yet (records:null) still builds a correct tree.
|
||||
// ⚠ NULL MEANS "KEEP YOURS", NOT "NO TITLE". 18 of 28 zones decline (their labels are sub-national or
|
||||
// taxonomic, and Intl has no subdivisions), so the fallback is the normal path, not the error path.
|
||||
const titleOf = typeof opts.title === 'function'
|
||||
? (zone, fallback) => opts.title(zone) || fallback
|
||||
: (_zone, fallback) => fallback;
|
||||
const lists = clusters.lists || [];
|
||||
const asia = clusters.asia_lists || [];
|
||||
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
|
||||
|
|
@ -137,7 +147,7 @@ export function buildLegendNodes(clusters, opts = {}) {
|
|||
{
|
||||
id: l.id,
|
||||
zoneKey: l.id,
|
||||
label: l.label,
|
||||
label: titleOf(l, l.label),
|
||||
// Carry the province enumeration the region-name label replaced.
|
||||
...(l.provinceLabel ? { provinceLabel: l.provinceLabel } : {}),
|
||||
color: colorOf(l.id),
|
||||
|
|
@ -171,7 +181,7 @@ export function buildLegendNodes(clusters, opts = {}) {
|
|||
{
|
||||
id: r.id,
|
||||
zoneKey: r.id,
|
||||
label: r.label,
|
||||
label: titleOf(r, r.label),
|
||||
// The province enumeration the region-name label replaced. Carried through
|
||||
// like adm0/provinces above — a node rebuilt field-by-field silently drops
|
||||
// anything not named, which is exactly how adm0 and population were lost.
|
||||
|
|
@ -228,7 +238,7 @@ export function buildLegendNodes(clusters, opts = {}) {
|
|||
{
|
||||
id: u.id,
|
||||
zoneKey: u.id,
|
||||
label: u.label,
|
||||
label: titleOf(u, u.label),
|
||||
// Carry the province enumeration the region-name label replaced.
|
||||
...(u.provinceLabel ? { provinceLabel: u.provinceLabel } : {}),
|
||||
color: colorOf(u.id),
|
||||
|
|
|
|||
178
plugin-designs/my-upg-100-list/data/zone-title.js
vendored
Normal file
178
plugin-designs/my-upg-100-list/data/zone-title.js
vendored
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* zone-title — compose a zone's DISPLAY TITLE from parts that are ALREADY translated, so adding a
|
||||
* project list costs no new translation and adding a locale costs no code change.
|
||||
*
|
||||
* THE PROBLEM (Driver, 2026-09-27). Every zone label is a hand-written English string — "Middle East
|
||||
* Core", "Malabar Coast" — so the Arabic map reads English. Hand-translating them does not scale,
|
||||
* because UPG-100 is becoming a PATTERN: Project 7000 / 4700 / 3000 / 2000 / 700 each invent their own
|
||||
* ~100 clusters, and a per-title translation is a cost paid again for every list, forever. The Driver's
|
||||
* question was precisely "how do you generate a title without a new translation each time".
|
||||
*
|
||||
* ★ WHAT THE DATA ACTUALLY ALLOWS, measured before any of this was written (2026-09-27):
|
||||
*
|
||||
* SOURCE COVERAGE OF OUR DATA FAILURE MODE
|
||||
* country_code_label from the records API all 175 codes, every locale (absent field)
|
||||
* Intl.DisplayNames({type:'region'}) all 175, every locale THROWS on ISO-3
|
||||
* Intl.DisplayNames({type:'language'}) 213 of 2242 = 9.5% ECHOES THE CODE
|
||||
*
|
||||
* ⚠ LANGUAGE IS NOT A USABLE TITLE SOURCE, which kills the more attractive half of the original idea.
|
||||
* `new Intl.DisplayNames(['en'],{type:'language'}).of('kfa')` returns the STRING "kfa" — it does not
|
||||
* throw — so a miss prints a raw code onto a printed poster with nothing raising a hand. And the 90.5%
|
||||
* miss is STRUCTURAL rather than a gap someone can fill: unreached peoples speak small languages by
|
||||
* definition, and CLDR carries the big ones. So this module never names a language.
|
||||
*
|
||||
* ★ AND COUNTRY NAMES COME FROM THE RECORDS, NOT FROM Intl, which was the measurement that simplified
|
||||
* the whole design. The records API already returns `country_code_label` localized by its own `lang`
|
||||
* parameter — verified live: IND is الهند / भारत / 印度 / Индия for lang=ar/hi/zh/ru, matching Intl
|
||||
* exactly. Since the packet and the page ALREADY fetch these records at the reader's locale, the
|
||||
* localized name is free and needs no ISO-3166 alpha-3 → alpha-2 mapping at all. Intl is kept only as a
|
||||
* FALLBACK for a caller who has a code and no record.
|
||||
*
|
||||
* WHICH ZONES THIS CAN AND CANNOT TITLE — stated here because a generator that silently produces a bad
|
||||
* title is worse than one that declines:
|
||||
* TIER A selector country_code → one country name. GENERATED
|
||||
* TIER B selector country_codes → 2..n country names + a joiner. GENERATED
|
||||
* TIER C wagf_block / wagf_region / pg_slugs / union / affinity_bloc → null. DECLINED
|
||||
* Tier C is 18 of our 28 zones and it cannot be done from country names: the 12 India/China zones are
|
||||
* `pg_slugs` whose every record is ONE country (measured: asia-india-kerala is 78/78 IND), so countries
|
||||
* would render "India" twelve times and could not tell "Malabar Coast" from "Northwest India" — those
|
||||
* labels are SUB-NATIONAL. Naming them needs a province/subdivision source, which Intl has none of; the
|
||||
* curated-table-vs-keep-English choice is with the Driver (repo's digest item D). Until it is decided
|
||||
* this returns null for tier C and the caller keeps the existing label, so nothing regresses.
|
||||
*
|
||||
* THE ONLY TRANSLATED STRINGS ARE THE TWO JOINERS — `zone_join` ("{a} + {b}") and `zone_join_more`
|
||||
* ("{a} + {n} more") — in packet-strings.json, one pair per locale block. That is the entire
|
||||
* translation cost for every project list that ever uses this.
|
||||
*/
|
||||
|
||||
/** Tier A/B selector types. Anything else is tier C and declined. */
|
||||
const COUNTRY_SELECTORS = new Set(['country_code', 'country_codes'])
|
||||
|
||||
/**
|
||||
* The ISO-3 codes a selector names, in the order the selector lists them.
|
||||
* ⚠ ORDER IS THE AUTHOR'S, NOT ALPHABETICAL. clusters.json lists the countries of a zone with the
|
||||
* biggest/most representative first (middle-east-core starts IRN,YEM,IRQ), and that order is editorial
|
||||
* information: it is what makes "Iran + Yemen + 8 more" a better title than "Bahrain + Iran + 8 more".
|
||||
* Sorting here would discard a judgement someone already made.
|
||||
*/
|
||||
export function zoneCountryCodes(selector) {
|
||||
if (!selector || !COUNTRY_SELECTORS.has(selector.type)) return []
|
||||
if (selector.type === 'country_code') return selector.value ? [selector.value] : []
|
||||
return Array.isArray(selector.values) ? selector.values.filter(Boolean) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO-3 → a localized country name.
|
||||
*
|
||||
* @param {string} iso3
|
||||
* @param {object} opts
|
||||
* @param {Map<string,string>} [opts.byCode] ISO-3 → country_code_label, built from the records this
|
||||
* page/packet already loaded AT THE READER'S LOCALE. The preferred source: no alpha-2 mapping,
|
||||
* no second service, and it agrees with Intl where both have an answer.
|
||||
* @param {string} [opts.locale] used only by the Intl fallback.
|
||||
* @param {(iso3:string, locale:string)=>string} [opts.countryName] regions-plugin's coming library
|
||||
* helper (their card BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2). Injected rather than imported
|
||||
* so this module does not depend on an unlanded file; when it lands, pass it and it wins over
|
||||
* the local Intl fallback.
|
||||
* @returns {string|null} null rather than the raw code — see below.
|
||||
*/
|
||||
export function countryLabel(iso3, opts = {}) {
|
||||
const code = String(iso3 || '').toUpperCase()
|
||||
if (!code) return null
|
||||
// ★ ORDER: regions' helper (or Intl) FIRST, the records' label SECOND — and this order is the
|
||||
// opposite of the one I wrote first, corrected by measuring what the two sources actually say.
|
||||
// The records API returns the FORMAL country name, which is wrong for a poster header:
|
||||
// LAO "Lao People's Democratic Republic" vs Intl "Laos"
|
||||
// IRN "Islamic Republic of Iran" vs Intl "Iran"
|
||||
// CHN "People's Republic of China" vs Intl "China"
|
||||
// 28 of our 175 country labels are long formal forms like these. My first version preferred the
|
||||
// records because they were already localized and needed no code mapping — true, and it produced
|
||||
// "Lao People's Democratic Republic" as the title of the zone whose hand-written label was "Laos",
|
||||
// i.e. a generated title WORSE than the one it replaced. Intl gives the short common form in every
|
||||
// locale, which is what a header and a 380px legend row want.
|
||||
// The records stay as the fallback because they need no alpha-2 mapping and are always present.
|
||||
if (typeof opts.countryName === 'function') {
|
||||
const n = opts.countryName(code, opts.locale || 'en')
|
||||
if (n && n !== code) return n
|
||||
}
|
||||
// ⚠ Intl REGIONS TAKE ALPHA-2 AND THROW ON ALPHA-3 — `new Intl.DisplayNames(['en'],{type:'region'})
|
||||
// .of('IND')` is a RangeError, not a miss. So a 3-letter code needs a mapping, and `alpha2` is that
|
||||
// mapping, INJECTED rather than imported: regions-plugin owns the ISO-3→ISO-2 table (their card
|
||||
// BUILD-LOCALIZED-COUNTRY-NAMES-ISO3-TO-ISO2) and it has not landed, so this module must work without
|
||||
// it and improve when it arrives. With no mapper a 3-letter code simply falls through to the records
|
||||
// label below — the formal name, but a real localized name.
|
||||
// That THROW-vs-ECHO asymmetry with the language type is also why every lookup here ends in null
|
||||
// rather than in a code: one failure is loud, the other silent, and null normalises both.
|
||||
const two = code.length === 2 ? code
|
||||
: (typeof opts.alpha2 === 'function' ? String(opts.alpha2(code) || '').toUpperCase() : '')
|
||||
if (two.length === 2) {
|
||||
try {
|
||||
const n = new Intl.DisplayNames([opts.locale || 'en'], { type: 'region' }).of(two)
|
||||
if (n && n !== two) return n
|
||||
} catch (_) { /* not a region Intl knows */ }
|
||||
}
|
||||
// The records' own label: always localized, always present, but the FORMAL form. Second choice, not
|
||||
// no choice.
|
||||
const fromRecords = opts.byCode?.get(code)
|
||||
if (fromRecords) return fromRecords
|
||||
// ★ NEVER RETURN THE CODE. "SDN + 3 more" on a poster looks like a data bug to a reader and, worse,
|
||||
// reads as a successful translation to a gate that only checks the string is non-empty. A null makes
|
||||
// the caller fall back to the existing label, which is English but is at least a WORD.
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The generated title for a zone, or null if this zone is not the kind that can be generated.
|
||||
*
|
||||
* @param {object} zone a clusters.json zone (needs .selector)
|
||||
* @param {object} strings the resolved locale block from packet-strings.json (per-KEY fallback to en
|
||||
* already applied by the caller, as packet.mjs does)
|
||||
* @param {object} opts as countryLabel, plus:
|
||||
* @param {number} [opts.maxParts=2] how many countries to name before collapsing to "+ n more".
|
||||
* 2 by default because these titles print on a poster header and in a legend row: three names
|
||||
* already wrap at the legend's 380px panel width in ar and hi, where the words are longer.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function zoneTitle(zone, strings, opts = {}) {
|
||||
const codes = zoneCountryCodes(zone?.selector)
|
||||
if (!codes.length) return null // tier C — declined, caller keeps its label
|
||||
const maxParts = Math.max(1, opts.maxParts ?? 2)
|
||||
|
||||
const named = codes.map((c) => countryLabel(c, opts))
|
||||
// ★ ALL-OR-NOTHING ON THE NAMED HEAD. If any of the countries we would SHOW has no name, decline the
|
||||
// whole title rather than printing a partial one: a title that names two of three countries and drops
|
||||
// the third silently misinforms, and it cannot be distinguished from a correct title downstream.
|
||||
const head = named.slice(0, maxParts)
|
||||
if (head.some((n) => !n)) return null
|
||||
|
||||
if (codes.length === 1) return head[0]
|
||||
if (codes.length <= maxParts) {
|
||||
// Fold left so three names read "a + b + c" using the same two-part joiner, rather than needing a
|
||||
// separate three-part string per locale.
|
||||
return head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`)
|
||||
}
|
||||
const rest = codes.length - maxParts
|
||||
const joined = head.reduce((acc, n) => fill(strings?.zone_join, { a: acc, b: n }) ?? `${acc} + ${n}`)
|
||||
return fill(strings?.zone_join_more, { a: joined, n: rest }) ?? `${joined} + ${rest} more`
|
||||
}
|
||||
|
||||
/** `{a}` / `{b}` / `{n}` interpolation. Returns null on a missing pattern so the caller can default. */
|
||||
function fill(pattern, params) {
|
||||
if (!pattern) return null
|
||||
return String(pattern).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO-3 → localized country label, from records the caller already has.
|
||||
* Built once per render rather than scanned per zone: 2107 records x 28 zones is 59k scans for data
|
||||
* that does not change during a render.
|
||||
*/
|
||||
export function countryLabelIndex(records) {
|
||||
const m = new Map()
|
||||
for (const r of records || []) {
|
||||
const cc = r?.country_code
|
||||
const label = r?.country_code_label
|
||||
if (cc && label && !m.has(cc)) m.set(String(cc).toUpperCase(), label)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
|
@ -612,14 +612,32 @@ useShadowStyles(
|
|||
// The detail panel is the legend's mirror image (it sits top-RIGHT in LTR), so it has to swap too or
|
||||
// the two would trade places and collide on the other side.
|
||||
'[dir="rtl"] .upg-detail-panel{right:auto!important;left:8px!important}' +
|
||||
// ⚠ AND THE ROWS THEMSELVES. dir on the wrapper does NOT reach content that sets its own direction,
|
||||
// and the legend's rows render zone names + numbers that must read right-to-left as a unit. Without
|
||||
// this the panel mirrors but its contents stay LTR — which is what the picture shows.
|
||||
'[dir="rtl"] .stl-panel,[dir="rtl"] .stl-panel *{direction:rtl}' +
|
||||
// Numbers stay LTR inside an RTL line: a count or a percentage is a left-to-right run even in Arabic,
|
||||
// and without isolation the bidi algorithm reorders a run that STARTS with a digit. Same fix the
|
||||
// packet needed for scripture references.
|
||||
'[dir="rtl"] .stl-panel .stl-count,[dir="rtl"] .stl-panel .stl-pop{direction:ltr;unicode-bidi:isolate}' +
|
||||
// ★ THE TWO LEGEND-CONTENT RULES THAT USED TO SIT HERE ARE DELETED (2026-09-27), and they were NOT
|
||||
// equally provisional — one was redundant and the other was DEAD, which is worth separating because
|
||||
// only one of them was ever doing anything.
|
||||
//
|
||||
// '[dir="rtl"] .stl-panel,[dir="rtl"] .stl-panel *{direction:rtl}'
|
||||
// REDUNDANT. It matched real elements (.stl-panel appears 69 times in SemanticTreeLegend) but the
|
||||
// library gets there on its own: it uses the :dir(rtl) PSEUDO-CLASS (:645-646), which reads the
|
||||
// INHERITED direction rather than needing a declaration, and CSS `direction` inherits across the
|
||||
// shadow boundary. rega measured the consequence at main 4ca2dcf9 (row 56b): Simple Research and
|
||||
// Prayer Progress, which carry NO override at all, compute direction rtl on their legend rows under
|
||||
// ?lang=ar. A rule whose effect is indistinguishable from its absence on two other profiles is not
|
||||
// load-bearing here either.
|
||||
//
|
||||
// '[dir="rtl"] .stl-panel .stl-count,[dir="rtl"] .stl-panel .stl-pop{direction:ltr;unicode-bidi:isolate}'
|
||||
// ⚠ DEAD CSS, and it always was. The library's count and population cells are `.stl-num` (:1314-1315).
|
||||
// `.stl-count` and `.stl-pop` DO NOT EXIST — `git grep` finds those two class names nowhere in
|
||||
// library/ or any other plugin, only inside this override. So this rule matched zero elements from the
|
||||
// day it was written, and the digit order rega verified ("694", "1,413" keeping their order under ar)
|
||||
// was always the library's doing, never mine.
|
||||
// ★ THE LESSON IS ABOUT THE EVIDENCE, NOT THE CSS: this rule was written to fix a real bidi problem,
|
||||
// was believed to have fixed it, and was cited as the reason the numbers read correctly — while
|
||||
// matching nothing. A style rule fails SILENTLY when its selector is wrong; it does not warn, and the
|
||||
// page looks right if something else is already handling it. The only way to know a rule works is to
|
||||
// check that its selector exists, which is a different act from looking at the rendered page.
|
||||
// (dx-shepherd UPGDEL authorised deleting both as provisional; recording here that one of the two was
|
||||
// never provisional but inert, so nobody re-adds it believing it once worked.)
|
||||
// ⚠ AND THE REOPEN BUTTON, which I missed on my first pass and repo caught. SemanticTreeLegend has
|
||||
// TWO physical `left:8px` rules, not one: :619 for the panel and :921 for .stl-reopen, the button that
|
||||
// brings the panel back after it is collapsed. Mirroring only the panel would leave the button on the
|
||||
|
|
@ -2192,10 +2210,10 @@ onMounted(async () => {
|
|||
// and it overlapped nothing: a promise created after an await starts only once that await
|
||||
// resolves, so the code read as parallel and behaved strictly serially.
|
||||
const boundaryPromises = {
|
||||
adm0: getJSON('world-adm0.geojson'),
|
||||
adm0: getJSON('world-adm0-web.geojson'),
|
||||
adm1Chn: getJSON('gb-CHN-ADM1.geojson'),
|
||||
// 1,628 KB, and only the India pg_slugs zones need it.
|
||||
adm1Ind: getJSON('gb-IND-ADM1.geojson'),
|
||||
adm1Ind: getJSON('gb-IND-ADM1-web.geojson'),
|
||||
}
|
||||
|
||||
// ── live records WITH rop1 + population (full record; LESSON L3 / slice 04u) ──
|
||||
|
|
|
|||
|
|
@ -193,14 +193,66 @@ function paneElement(pane) {
|
|||
// `lang` is the alias the page may carry; children read `locale`.
|
||||
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
|
||||
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
|
||||
// ★ NO "TAP A COLOURED DOT" COACH-MARK ON THIS PROFILE, AT ALL (Driver, 2026-09-27).
|
||||
// ProfileLoader mounts PinHint by default and self-gates it on a map <canvas> existing, which is a
|
||||
// sound gate for the question it asks ("is there a map here?") and the WRONG question for this
|
||||
// profile. UPG-100's map is a POSTER PREVIEW: the reader is looking at what will be printed, and there
|
||||
// is no per-dot detail to open. So the pill invites a gesture that does nothing, and on the phone the
|
||||
// Driver's screenshots show it over a page with no coloured dots on it at all.
|
||||
// ⚠ THIS SUPERSEDES THE inert WORK FOR THIS PROFILE, and that is worth saying plainly rather than
|
||||
// quietly leaving both in: PILLRISK was "the pill shows over the posters", and I fixed WHERE it shows.
|
||||
// The Driver's answer is that it should not show HERE, full stop — a narrower and more correct claim
|
||||
// than mine. The inert signal stays because it is still right for its own reasons (a stacked pane
|
||||
// should not be in tab order or the a11y tree, and studio's PinHint change serves profiles that DO
|
||||
// want the pill), but it is no longer what keeps this pill off this app.
|
||||
// Opt-out via the contract that already existed — ProfileLoader.vue:150 reads
|
||||
// `config.pinHint !== false` — so this is a config value from my side, not a library change.
|
||||
pcfg.pinHint = false
|
||||
if (tk.value) pcfg.tk = tk.value
|
||||
el.setAttribute('profile-config', JSON.stringify(pcfg))
|
||||
return el
|
||||
}
|
||||
|
||||
// ★ TELL THE INVISIBLE PANE THAT IT IS INVISIBLE. `inert` on every pane except the active one.
|
||||
//
|
||||
// THE DEFECT, reported by the Driver and traced by repo (PILLRISK, 2026-09-27): PinHint's "Tap a
|
||||
// coloured dot" pill appeared OVER the Prayer Resources posters on a phone. Its IntersectionObserver
|
||||
// was working correctly — that is the point. The panes are `position:absolute; inset:0` in one
|
||||
// scroll-snap column, so the map pane is STACKED BEHIND the active view at full size: 100% of its
|
||||
// geometry is inside the viewport while none of it is visible. An IntersectionObserver measures
|
||||
// INTERSECTION, not OCCLUSION, and no amount of tuning it can distinguish those two states. The
|
||||
// missing information was never geometric — only the composite knows which pane the reader chose.
|
||||
//
|
||||
// ⚠ inert, NOT display:none OR v-show, and this is the constraint that decides the whole approach:
|
||||
// hiding the pane would tear down the live map. A MapLibre/Mapbox canvas in a zero-size container
|
||||
// loses its size and re-initialises on the way back, which is exactly the cold-start path rega had to
|
||||
// verify in row 52. inert leaves the pane mounted, painted and warm; it only declares it non-interactive
|
||||
// — and, unlike aria-hidden, also removes it from tab order and the accessibility tree, so a keyboard
|
||||
// user cannot tab into a map they cannot see. (studio's contract, END-STU-PILL: PinHint walks up through
|
||||
// shadow roots for inert/hidden/aria-hidden and watches that chain with a MutationObserver, so no event
|
||||
// is needed and switching views brings the pill straight back.)
|
||||
//
|
||||
// ★ CORRECT AT EVERY VIEWPORT, not just the phone. pane-wrapper renders ONE column with
|
||||
// `scroll-snap-type: y mandatory` and `.pane { height: 100% }`, so exactly one pane fills the stage on
|
||||
// any width — there is no side-by-side mode in v1 whose second pane would be wrongly marked. Verified
|
||||
// in pane-wrapper.js:56-59 + :101 rather than assumed from the phone report.
|
||||
function markActivePane(activeId) {
|
||||
if (!column) return
|
||||
for (const pane of column.panes) {
|
||||
const el = column.mountEl(pane.id)
|
||||
if (!el) continue
|
||||
if (pane.id === activeId) el.removeAttribute('inert')
|
||||
else el.setAttribute('inert', '')
|
||||
}
|
||||
}
|
||||
|
||||
function go(id) {
|
||||
view.value = id
|
||||
column?.scrollTo(id)
|
||||
// Immediately, not on the scroll callback: scrollTo is SMOOTH, so onScroll's 120ms debounce fires
|
||||
// only after the animation settles. Waiting for it would leave the pill visible over the posters for
|
||||
// the length of the scroll — a shorter version of the same bug.
|
||||
markActivePane(id)
|
||||
}
|
||||
|
||||
// Cross-pane action (shell seam): the poster wall dispatches a scoped window event
|
||||
|
|
@ -253,10 +305,21 @@ onMounted(() => {
|
|||
paneEls[pane.id] = el
|
||||
column.mountEl(pane.id)?.appendChild(el)
|
||||
}
|
||||
// ★ THE FRESH-LOAD CASE, which is the one the Driver actually hit. Without this the panes carry no
|
||||
// inert attribute at all until the reader touches the switcher, so the pill is over the posters on
|
||||
// ARRIVAL — before any view change has happened. A signal that only fires on transitions never
|
||||
// describes the initial state.
|
||||
markActivePane(column.current())
|
||||
|
||||
// Tabs are a readout as well as a control — follow the user's scroll.
|
||||
offScroll = column.onScroll(() => {
|
||||
const cur = column.current()
|
||||
if (cur && cur !== view.value) view.value = cur
|
||||
// Unconditionally, NOT inside the `cur !== view.value` guard above: go() has already set view.value
|
||||
// before this fires, so a switcher-driven scroll takes the early exit and would skip the marking. A
|
||||
// scroll-driven one needs it and a switcher-driven one is idempotent, so doing it always is both
|
||||
// cheaper to reason about and the only version that is correct on both paths.
|
||||
if (cur) markActivePane(cur)
|
||||
})
|
||||
window.addEventListener('doxa-upg-100-list:show-on-map', onShowOnMap)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ useShadowStyles(
|
|||
<div class="pdm-root">
|
||||
<div class="pdm-bar">
|
||||
<span class="pdm-brand">PPLR data maps</span>
|
||||
<div class="pdm-seg" role="tablist" aria-label="Choose a view">
|
||||
<div class="pdm-seg" role="tablist" :aria-label="t('pplr.ui.chooseView')">
|
||||
<button v-for="v in VIEWS" :key="v.key" role="tab" :aria-selected="view === v.key"
|
||||
:class="{ on: view === v.key }" @click="setView(v.key)">{{ t(v.tKey) }}</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
* component into its toolbar.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
|
||||
defineProps({
|
||||
/** Array of { label, url, fields[] } */
|
||||
|
|
@ -16,19 +17,20 @@ defineProps({
|
|||
})
|
||||
|
||||
const open = ref(false)
|
||||
const t = usePplrT()
|
||||
function toggle() { open.value = !open.value }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dsd-wrap">
|
||||
<button class="toolbar-btn round" type="button" title="Data sources" @click="toggle">
|
||||
<button class="toolbar-btn round" type="button" :title="t('pplr.ui.dataSources')" @click="toggle">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="open" class="dsd-panel">
|
||||
<div class="dsd-header">Data sources</div>
|
||||
<div class="dsd-header">{{ t('pplr.ui.dataSources') }}</div>
|
||||
<div v-for="src in sources" :key="src.url" class="dsd-src">
|
||||
<div class="dsd-src-label">{{ src.label }}</div>
|
||||
<div class="dsd-src-url">{{ src.url }}</div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { useInstance } from '../composables/usePplrInstance.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
|
|
@ -30,6 +31,7 @@ const props = defineProps({
|
|||
const emit = defineEmits(['select'])
|
||||
|
||||
const instance = useInstance()
|
||||
const t = usePplrT()
|
||||
const collapsed = ref(false)
|
||||
|
||||
const theme = computed(() => instance?.theme.value || 'dark')
|
||||
|
|
@ -77,7 +79,7 @@ function clearSel() {
|
|||
</svg>
|
||||
</button>
|
||||
<div class="fl-title">{{ title }}</div>
|
||||
<button v-if="selectedKey" class="fl-clear" title="Clear selection (Esc)" @click="clearSel">×</button>
|
||||
<button v-if="selectedKey" class="fl-clear" :title="t('pplr.ui.clearSelectionEsc')" @click="clearSel">×</button>
|
||||
</div>
|
||||
<div class="fl-rows" :class="{ 'has-sel': !!selectedKey }">
|
||||
<button
|
||||
|
|
@ -99,12 +101,12 @@ function clearSel() {
|
|||
<button v-if="collapsed"
|
||||
class="fl-reopen"
|
||||
:data-theme="theme"
|
||||
title="Open legend"
|
||||
:title="t('pplr.ui.openLegend')"
|
||||
@click="collapsed = false">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||
<polyline points="9,18 15,12 9,6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>Legend</span>
|
||||
<span>{{ t('pplr.ui.legend') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@
|
|||
* <LoadingGlobe v-if="!isReady" :label="loadingStage" @retry="retry" />
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, default: 'Loading map' },
|
||||
label: { type: String, default: '' }, // empty = the translated "Loading map"
|
||||
stuckAfterMs:{ type: Number, default: 30000 },
|
||||
})
|
||||
const emit = defineEmits(['retry'])
|
||||
const t = usePplrT()
|
||||
|
||||
const stuck = ref(false)
|
||||
let stuckTimer = null
|
||||
|
|
@ -56,13 +58,13 @@ function onRetry() {
|
|||
<line x1="6" y1="32" x2="58" y2="32" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
|
||||
<line x1="32" y1="6" x2="32" y2="58" stroke="#73A17F" stroke-width="1.2" opacity="0.45"/>
|
||||
</svg>
|
||||
<span class="lg-text">{{ label }}</span>
|
||||
<span class="lg-text">{{ label || t('pplr.ui.loadingMap') }}</span>
|
||||
<button v-if="stuck" class="lg-retry" type="button" @click="onRetry">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 1 1-3-6.7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<polyline points="21 4 21 11 14 11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Retry
|
||||
{{ t('pplr.ui.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|||
import { useInstance } from '../composables/usePplrInstance.js'
|
||||
import { LOCALES, LOCALE_NAMES } from '../utils/pplrMap.js'
|
||||
import { loadNames } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** Optional override — usually read from instance.pinDetail */
|
||||
|
|
@ -30,6 +31,7 @@ const props = defineProps({
|
|||
const emit = defineEmits(['close', 'locate'])
|
||||
|
||||
const instance = useInstance()
|
||||
const tr = usePplrT() // not `t`: the names table below is already called t
|
||||
const pinData = computed(() => props.pin || instance?.pinDetail.value || null)
|
||||
|
||||
// All 17 locale names for this PG (n_en, n_hi, n_bn, …). Per user 2026-05-02:
|
||||
|
|
@ -59,12 +61,12 @@ const fields = computed(() => {
|
|||
const dash = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
|
||||
// Curated field order — most identifying first, registry IDs last
|
||||
return [
|
||||
{ label: 'Country', value: dash(p.country), mono: false },
|
||||
{ label: 'Religion', value: dash(p.religion), mono: false },
|
||||
{ label: 'Language', value: dash(p.language), mono: false },
|
||||
{ label: 'ISO-3 Country', value: dash(p.iso3), mono: true, hidden: !p.iso3 },
|
||||
{ label: 'Affinity Bloc', value: dash(p.rop1), mono: true, hidden: !p.rop1 },
|
||||
{ label: 'People Cluster', value: dash(p.rop2), mono: true, hidden: !p.rop2 },
|
||||
{ label: tr('pplr.popup.country'), value: dash(p.country), mono: false },
|
||||
{ label: tr('pplr.popup.religion'), value: dash(p.religion), mono: false },
|
||||
{ label: tr('pplr.popup.language'), value: dash(p.language), mono: false },
|
||||
{ label: tr('pplr.popup.iso3Country'), value: dash(p.iso3), mono: true, hidden: !p.iso3 },
|
||||
{ label: tr('pplr.popup.affinityBloc'), value: dash(p.rop1), mono: true, hidden: !p.rop1 },
|
||||
{ label: tr('pplr.popup.peopleCluster'), value: dash(p.rop2), mono: true, hidden: !p.rop2 },
|
||||
{ label: 'ROP25', value: dash(p.rop3), mono: true, hidden: !p.rop3 },
|
||||
{ label: 'PGIC GeoID', value: dash(p.rop25), mono: true, hidden: !p.rop25 },
|
||||
{ label: 'PEID', value: dash(p.peid), mono: true, hidden: !p.peid },
|
||||
|
|
@ -98,8 +100,8 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
|||
|
||||
<!-- Title + close -->
|
||||
<div class="pdc-header">
|
||||
<span class="pdc-eyebrow">People Group</span>
|
||||
<button class="pdc-close" title="Close (Esc)" @click="close">
|
||||
<span class="pdc-eyebrow">{{ tr('pplr.popup.peopleGroup') }}</span>
|
||||
<button class="pdc-close" :title="tr('pplr.ui.closeEsc')" @click="close">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
|
@ -117,7 +119,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
|||
</div>
|
||||
|
||||
<div class="pdc-section-divider">
|
||||
<span>Name in 17 locales</span>
|
||||
<span>{{ tr('pplr.card.nameInLocales', { n: locales.length }) }}</span>
|
||||
</div>
|
||||
<div v-for="loc in locales" :key="loc.code" class="pdc-field pdc-locale">
|
||||
<span class="pdc-field-label">{{ loc.name }} <code>{{ loc.code }}</code></span>
|
||||
|
|
@ -134,9 +136,9 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
|||
<path d="M12 21s7-7 7-12a7 7 0 1 0-14 0c0 5 7 12 7 12z" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="12" cy="9" r="2.5" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
Fly to pin
|
||||
{{ tr('pplr.ui.flyToPin') }}
|
||||
</button>
|
||||
<button class="pdc-btn" @click="close">Close</button>
|
||||
<button class="pdc-btn" @click="close">{{ tr('pplr.ui.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const A11Y_EN = {
|
|||
'legend.loadingRows': 'Loading {label}…', 'legend.loadingData': 'Loading data…',
|
||||
'legend.noDataAtThisLevel': 'No data at this level', 'legend.showingTop': 'Showing top {n} of {m}',
|
||||
'legend.useGeocoderHint': 'use the geocoder above to find the rest',
|
||||
'legend.sortByPop': 'Sort by Population',
|
||||
}
|
||||
const fillA11y = (str, p) => String(str).replace(/\{(\w+)\}/g, (m, k) => (p && k in p ? p[k] : m))
|
||||
let ta = (key, p) => fillA11y(A11Y_EN[key] ?? key, p)
|
||||
|
|
@ -441,7 +442,7 @@ function rowClasses(row) {
|
|||
</button>
|
||||
<button v-if="hasPop" class="stl-hdr-num"
|
||||
:class="{ active: sortBy === 'pop' }"
|
||||
title="Sort by Population"
|
||||
:title="ta('legend.sortByPop')"
|
||||
@click="clickSortHeader('pop')">
|
||||
Pop
|
||||
<span v-if="sortBy === 'pop'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const LOCALE_NAMES = {
|
|||
|
||||
// ── PPLR FeatureServer — country polygons (live, not bundled) ────────────────
|
||||
import { dataUrl, glFor } from './pplrData.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
|
||||
export const COUNTRY_FS_URL = 'https://services2.arcgis.com/S4ydGgujXcif36k3/arcgis/rest/services/aCountry/FeatureServer/0/query'
|
||||
export const COUNTRY_FS_FIELDS = ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp']
|
||||
|
|
@ -332,17 +333,20 @@ export const escHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&':
|
|||
|
||||
export function popupForPG(p, extraRows = '') {
|
||||
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : escHtml(v))
|
||||
// Labels in the page's language (tStatic falls back to English). Registry names (ROP3, ROP25,
|
||||
// JoshuaProject, IMB People, WCD) are identifiers and stay as they are.
|
||||
const L = (k) => escHtml(tStatic('pplr.popup.' + k))
|
||||
return `<div class="pplr-popup">
|
||||
<h3>${p.name ? escHtml(p.name) : '(no name)'}</h3>
|
||||
<h3>${p.name ? escHtml(p.name) : L('noName')}</h3>
|
||||
<table>
|
||||
<tr><td>Country</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
|
||||
<tr><td>${L('country')}</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
|
||||
<tr><td>ROP3</td><td><code>${dash(p.rop3)}</code></td></tr>
|
||||
<tr><td>ROP25 (Ethne)</td><td><code>${dash(p.rop25)}</code></td></tr>
|
||||
<tr><td>JoshuaProject</td><td><code>${dash(p.jp)}</code></td></tr>
|
||||
<tr><td>IMB People</td><td><code>${dash(p.imb)}</code></td></tr>
|
||||
<tr><td>WCD</td><td><code>${dash(p.wcd)}</code></td></tr>
|
||||
<tr><td>Religion</td><td>${dash(p.religion)}</td></tr>
|
||||
<tr><td>Language</td><td>${dash(p.language)}</td></tr>
|
||||
<tr><td>${L('religion')}</td><td>${dash(p.religion)}</td></tr>
|
||||
<tr><td>${L('language')}</td><td>${dash(p.language)}</td></tr>
|
||||
${extraRows}
|
||||
</table>
|
||||
</div>`
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ onMounted(async () => {
|
|||
const html = `<div class="pplr-popup">
|
||||
<h3>${escHtml(p.CtryNm || p.ctryNmDisp || '—')}<small>${escHtml(p.GENCalpha3 || p.ISOalpha2 || '?')}</small></h3>
|
||||
<table>
|
||||
<tr><td>PG count</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
|
||||
<tr><td>Avg locales filled</td><td>
|
||||
<tr><td>${escHtml(t('pplr.popup.pgCount'))}</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.avgLocalesFilled'))}</td><td>
|
||||
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
|
||||
</table>
|
||||
</div>`
|
||||
|
|
@ -148,9 +148,9 @@ const COV_POPUP_CSS = POPUP_CSS + `
|
|||
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
|
||||
<div ref="mapContainer" class="map" />
|
||||
<div class="status">{{ status }}</div>
|
||||
<div class="header-pill">Translation coverage · choropleth = avg locales filled per country · click PG pin or country</div>
|
||||
<div class="header-pill">{{ t('pplr.pill.coverage') }}</div>
|
||||
<div class="legend">
|
||||
<div class="title">Avg locales / 17</div>
|
||||
<div class="title">{{ t('pplr.legendTitle.avgLocales', { n: 17 }) }}</div>
|
||||
<div class="bar">
|
||||
<div v-for="s in SCALE" :key="s.stop" class="seg" :style="{ background: s.color }">
|
||||
<span v-if="s.label">{{ s.label }}</span>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ onMounted(async () => {
|
|||
|
||||
<template>
|
||||
<div class="profile">
|
||||
<LoadingGlobe v-if="!tree" label="Loading ROP hierarchy" @retry="reload" />
|
||||
<LoadingGlobe v-if="!tree" :label="t('pplr.status.loadingRop')" @retry="reload" />
|
||||
<template v-else>
|
||||
<SemanticSunburst
|
||||
:tree="tree"
|
||||
|
|
@ -74,18 +74,18 @@ onMounted(async () => {
|
|||
:depth-labels="['','Affinity Bloc','People Cluster','PG (not rendered)']"
|
||||
:max-depth="2"
|
||||
/>
|
||||
<div class="header-pill">ROP Hierarchy · Affinity Bloc → People Cluster · scroll to zoom</div>
|
||||
<div class="header-pill">{{ t('pplr.pill.sunburst') }}</div>
|
||||
<div class="status">{{ status }}</div>
|
||||
<div class="map-toolbar">
|
||||
<div class="settings-wrap">
|
||||
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<div class="settings-header">Data sources</div>
|
||||
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
|
||||
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
|
||||
<div class="settings-src-label">{{ src.label }}</div>
|
||||
<div class="settings-src-url">{{ src.url }}</div>
|
||||
|
|
|
|||
|
|
@ -415,10 +415,10 @@ function popupHtml(p) {
|
|||
return `<div class="pplr-popup">
|
||||
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
|
||||
<table>
|
||||
<tr><td>Language</td><td>${d(p.language)}</td></tr>
|
||||
<tr><td>Family</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>
|
||||
<tr><td>Affinity Bloc</td><td>${d(p.rop1)}</td></tr>
|
||||
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.family'))}</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.affinityBloc'))}</td><td>${d(p.rop1)}</td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td>${d(p.religion)}</td></tr>
|
||||
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
|
||||
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
|
||||
</table>
|
||||
|
|
@ -522,7 +522,7 @@ const LF_CSS = POPUP_CSS + `
|
|||
:local-index="langLocalIndex"
|
||||
:kind-labels="LANG_KIND_LABELS"
|
||||
:mapbox-token="mapboxToken"
|
||||
placeholder="Search by people, place, language, religion…"
|
||||
:placeholder="t('pplr.ui.search')"
|
||||
@place="onGeocoderPlace"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -539,13 +539,13 @@ const LF_CSS = POPUP_CSS + `
|
|||
v-else
|
||||
:nodes="langTree"
|
||||
:tabs="LANG_TABS"
|
||||
title="Language Families"
|
||||
:title="t('pplr.tabs.languageFamilies')"
|
||||
@select="onLegendSelect"
|
||||
/>
|
||||
|
||||
<div ref="mapContainer" class="map" />
|
||||
<div class="status">{{ status }}</div>
|
||||
<div class="header-pill">Ethnolinguistic families (PPLR) · click pin for detail</div>
|
||||
<div class="header-pill">{{ t('pplr.pill.languageFamilies') }}</div>
|
||||
|
||||
<!-- Toolbar — rounded pill controls -->
|
||||
<div class="map-toolbar">
|
||||
|
|
@ -564,28 +564,28 @@ const LF_CSS = POPUP_CSS + `
|
|||
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
|
||||
</button>
|
||||
<div class="basemap-wrap">
|
||||
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')" @click="showBasemap = !showBasemap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showBasemap" class="basemap-panel">
|
||||
<div class="basemap-title">Base map</div>
|
||||
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
|
||||
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
|
||||
:class="{ active: currentBasemap === b.key }"
|
||||
@click="setBasemap(b.key)">{{ b.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-wrap">
|
||||
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<div class="settings-header">Data sources</div>
|
||||
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
|
||||
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
|
||||
<div class="settings-src-label">{{ src.label }}</div>
|
||||
<div class="settings-src-url">{{ src.url }}</div>
|
||||
|
|
|
|||
|
|
@ -68,18 +68,18 @@ onBeforeUnmount(() => destroy())
|
|||
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
|
||||
<div ref="mapContainer" class="map" />
|
||||
<div class="status">{{ status }}</div>
|
||||
<div class="header-pill">All PG-in-country pins · click for cross-system IDs</div>
|
||||
<div class="header-pill">{{ t('pplr.pill.pins') }}</div>
|
||||
|
||||
<div class="map-toolbar">
|
||||
<div class="settings-wrap">
|
||||
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<div class="settings-header">Data sources</div>
|
||||
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
|
||||
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
|
||||
<div class="settings-src-label">{{ src.label }}</div>
|
||||
<div class="settings-src-url">{{ src.url }}</div>
|
||||
|
|
|
|||
|
|
@ -236,8 +236,8 @@ function popupHtml(p) {
|
|||
return `<div class="pplr-popup">
|
||||
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
|
||||
<table>
|
||||
<tr><td>Religion</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
|
||||
<tr><td>Language</td><td>${d(p.language)}</td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
|
||||
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
|
||||
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
|
||||
</table>
|
||||
|
|
@ -294,7 +294,7 @@ const REL_CSS = POPUP_CSS + `
|
|||
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
|
||||
<div ref="mapContainer" class="map" />
|
||||
<div class="status">{{ status }}</div>
|
||||
<div class="header-pill">Religion distribution · click pin for detail</div>
|
||||
<div class="header-pill">{{ t('pplr.pill.religion') }}</div>
|
||||
|
||||
<!-- Decoupled geocoder — searches religions + countries + places -->
|
||||
<div class="geocoder-slot">
|
||||
|
|
@ -302,7 +302,7 @@ const REL_CSS = POPUP_CSS + `
|
|||
:local-index="religionLocalIndex"
|
||||
:kind-labels="REL_KIND_LABELS"
|
||||
:mapbox-token="mapboxToken"
|
||||
placeholder="Search by people, place, language, religion…"
|
||||
:placeholder="t('pplr.ui.search')"
|
||||
@place="onGeocoderPlace"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -318,7 +318,7 @@ const REL_CSS = POPUP_CSS + `
|
|||
<FlatLegend
|
||||
v-else
|
||||
:items="religionLegendItems"
|
||||
title="Primary Religion"
|
||||
:title="t('pplr.ui.primaryReligion')"
|
||||
@select="onLegendSelect"
|
||||
/>
|
||||
|
||||
|
|
@ -339,27 +339,27 @@ const REL_CSS = POPUP_CSS + `
|
|||
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
|
||||
</button>
|
||||
<div class="basemap-wrap">
|
||||
<button class="toolbar-btn round" title="Base map" @click="showBasemap = !showBasemap">
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')" @click="showBasemap = !showBasemap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<path d="M3 12l9 4 9-4M3 17l9 4 9-4" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showBasemap" class="basemap-panel">
|
||||
<div class="basemap-title">Base map</div>
|
||||
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
|
||||
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt" :class="{ active: currentBasemap === b.key }" @click="setBasemap(b.key)">{{ b.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Data sources — moved into the toolbar to match ROP-tree (per user 2026-05-02) -->
|
||||
<div class="settings-wrap">
|
||||
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showSettings" class="settings-panel toolbar-attached">
|
||||
<div class="settings-header">Data sources</div>
|
||||
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
|
||||
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
|
||||
<div class="settings-src-label">{{ src.label }}</div>
|
||||
<div class="settings-src-url">{{ src.url }}</div>
|
||||
|
|
|
|||
|
|
@ -380,14 +380,14 @@ function popupHtml(p) {
|
|||
return `<div class="pplr-popup">
|
||||
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
|
||||
<table>
|
||||
<tr><td>Affinity Bloc</td><td><b>${d(p.rop1)}</b></td></tr>
|
||||
<tr><td>Cluster</td><td><b>${d(p.rop2)}</b></td></tr>
|
||||
<tr><td>People Group</td><td><b>${d(p.rop3)}</b></td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.affinityBloc'))}</td><td><b>${d(p.rop1)}</b></td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.cluster'))}</td><td><b>${d(p.rop2)}</b></td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.peopleGroup'))}</td><td><b>${d(p.rop3)}</b></td></tr>
|
||||
<tr><td>PEID</td><td><code>${d(p.peid)}</code></td></tr>
|
||||
<tr><td>IMB PGID</td><td><code>${d(p.imb)}</code></td></tr>
|
||||
<tr><td>JP Code</td><td><code>${d(p.jp)}</code></td></tr>
|
||||
<tr><td>Religion</td><td>${d(p.religion)}</td></tr>
|
||||
<tr><td>Language</td><td>${d(p.language)}</td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.religion'))}</td><td>${d(p.religion)}</td></tr>
|
||||
<tr><td>${escHtml(t('pplr.popup.language'))}</td><td>${d(p.language)}</td></tr>
|
||||
</table>
|
||||
</div>`
|
||||
}
|
||||
|
|
@ -491,7 +491,7 @@ const TREE_POPUP_CSS = POPUP_CSS + `
|
|||
:local-index="localIndex"
|
||||
:kind-labels="KIND_LABELS"
|
||||
:mapbox-token="mapboxToken"
|
||||
placeholder="Search by people, place, language, religion…"
|
||||
:placeholder="t('pplr.ui.search')"
|
||||
@place="onGeocoderPlace"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -508,13 +508,13 @@ const TREE_POPUP_CSS = POPUP_CSS + `
|
|||
v-else
|
||||
:nodes="ropTree"
|
||||
:tabs="ROP_TABS"
|
||||
title="ROP Hierarchy"
|
||||
:title="t('pplr.ui.ropHierarchy')"
|
||||
@select="onRopLegendSelect"
|
||||
/>
|
||||
|
||||
<div ref="mapContainer" class="map" />
|
||||
<div class="status">{{ status }}</div>
|
||||
<div class="header-pill">ROP Hierarchy · Affinity Bloc → Cluster → People Group → PGIC</div>
|
||||
<div class="header-pill">{{ t('pplr.pill.ropTree') }}</div>
|
||||
|
||||
<!-- Toolbar — rounded pill controls -->
|
||||
<div class="map-toolbar">
|
||||
|
|
@ -533,7 +533,7 @@ const TREE_POPUP_CSS = POPUP_CSS + `
|
|||
<span class="toolbar-icon">{{ projection === 'globe' ? '🌐' : '🗺' }}</span>
|
||||
</button>
|
||||
<div class="basemap-wrap">
|
||||
<button class="toolbar-btn round" title="Base map"
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.baseMap')"
|
||||
@click="showBasemap = !showBasemap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
|
|
@ -541,21 +541,21 @@ const TREE_POPUP_CSS = POPUP_CSS + `
|
|||
</svg>
|
||||
</button>
|
||||
<div v-if="showBasemap" class="basemap-panel">
|
||||
<div class="basemap-title">Base map</div>
|
||||
<div class="basemap-title">{{ t('pplr.ui.baseMap') }}</div>
|
||||
<button v-for="b in BASEMAPS" :key="b.key" class="basemap-opt"
|
||||
:class="{ active: currentBasemap === b.key }"
|
||||
@click="setBasemap(b.key)">{{ b.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-wrap">
|
||||
<button class="toolbar-btn round" title="Data sources" @click="showSettings = !showSettings">
|
||||
<button class="toolbar-btn round" :title="t('pplr.ui.dataSources')" @click="showSettings = !showSettings">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2.1-1.2L14 3h-4l-.4 2.6A7 7 0 0 0 7.5 6.8l-2.4-1-2 3.5 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2.1 1.2L10 21h4l.4-2.6a7 7 0 0 0 2.1-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<div class="settings-header">Data sources</div>
|
||||
<div class="settings-header">{{ t('pplr.ui.dataSources') }}</div>
|
||||
<div v-for="src in DATA_SOURCES" :key="src.url" class="settings-src">
|
||||
<div class="settings-src-label">{{ src.label }}</div>
|
||||
<div class="settings-src-url">{{ src.url }}</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
|||
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
|
||||
import { usePplrT } from '../composables/usePplrT.js'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
|
||||
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS, escHtml } from '../utils/pplrMap.js'
|
||||
import LoadingGlobe from '../components/LoadingGlobe.vue'
|
||||
import DataSourcesDropdown from '../components/DataSourcesDropdown.vue'
|
||||
|
||||
|
|
@ -123,11 +123,11 @@ onMounted(async () => {
|
|||
].filter(Boolean)
|
||||
|
||||
const rosettaRow = sample
|
||||
? `<tr><td>Rosetta ★</td><td><b style="color:#d97706">★ sample PG</b></td></tr>`
|
||||
? `<tr><td>Rosetta ★</td><td><b style="color:#d97706">${escHtml(t('pplr.rosetta.samplePg'))}</b></td></tr>`
|
||||
: ''
|
||||
const missingNote = missingIds.length
|
||||
? `<tr><td colspan="2" style="padding-top:6px;font-size:10px;color:#6b7280;font-style:italic">
|
||||
${missingIds.join(', ')} ID${missingIds.length > 1 ? 's' : ''} not yet cross-mapped in PPLR — empty rows are a data gap, not an error.
|
||||
${escHtml(t('pplr.rosetta.missingIds', { ids: missingIds.join(', ') }))}
|
||||
</td></tr>`
|
||||
: ''
|
||||
new (glFor(map.value)).Popup({ maxWidth: '360px' })
|
||||
|
|
@ -154,11 +154,11 @@ onMounted(async () => {
|
|||
onBeforeUnmount(() => destroy())
|
||||
|
||||
const LEGEND = [
|
||||
{ color: '#fbbf24', label: 'JP + IMB + WCD all' },
|
||||
{ color: '#fbbf24', label: t('pplr.rosetta.legend.all') },
|
||||
{ color: '#3b82f6', label: 'JP + IMB' },
|
||||
{ color: '#a78bfa', label: 'IMB only' },
|
||||
{ color: '#ec4899', label: 'JP only' },
|
||||
{ color: '#6b7280', label: 'neither' },
|
||||
{ color: '#a78bfa', label: t('pplr.rosetta.legend.imbOnly') },
|
||||
{ color: '#ec4899', label: t('pplr.rosetta.legend.jpOnly') },
|
||||
{ color: '#6b7280', label: t('pplr.rosetta.legend.neither') },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
@ -167,9 +167,9 @@ const LEGEND = [
|
|||
<LoadingGlobe v-if="!isMapReady || !dataReady" :label="status" @retry="reload" />
|
||||
<div ref="mapContainer" class="map" />
|
||||
<div class="status">{{ status }}</div>
|
||||
<div class="header-pill">Cross-system ID Rosetta · color = which IDs exist · ★ = sample row</div>
|
||||
<div class="header-pill">{{ t('pplr.pill.rosetta') }}</div>
|
||||
<div class="legend">
|
||||
<div class="title">ID coverage</div>
|
||||
<div class="title">{{ t('pplr.legendTitle.idCoverage') }}</div>
|
||||
<div v-for="l in LEGEND" :key="l.label" class="row">
|
||||
<span class="dot" :style="{ background: l.color }" />{{ l.label }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import { DXLIFE_TOKENS } from './dxlife-tokens.js'
|
|||
// labour country-poster-wall.vue states in its own header ("this profile supplies only its
|
||||
// data and taxonomy").
|
||||
import PosterWall from '@map/components/poster/PosterWall.vue'
|
||||
import RegionSectionCard from '@map/components/regions/RegionSectionCard.vue'
|
||||
import posterData from '@map/data/country-posters.json'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
|
|
@ -426,74 +427,6 @@ useShadowStyles(
|
|||
'.rl-summary{ margin:0; color:var(--dx-muted); font-size:var(--font-size-md); }' +
|
||||
'.rl-status{ padding:var(--spacing-xl); text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
|
||||
'.rl-status.rl-failed{ font-weight:600; }' +
|
||||
// .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
|
||||
|
|
@ -505,81 +438,6 @@ useShadowStyles(
|
|||
// 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'
|
||||
)
|
||||
</script>
|
||||
|
|
@ -625,96 +483,21 @@ useShadowStyles(
|
|||
<div v-else-if="!regions.length" class="rl-status">{{ t('No regions found.') }}</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
<RegionSectionCard
|
||||
v-for="region in regions"
|
||||
:key="region.slug"
|
||||
class="region-card"
|
||||
:title="region.label || 'Other'"
|
||||
:summary="summaryLine(region)"
|
||||
:countries="region.countries"
|
||||
:footer-label="dashboardLink(region)"
|
||||
:disclosure-label="postersOpen(region) ? t('Hide country posters') : t('View country posters')"
|
||||
:disclosure-open="postersOpen(region)"
|
||||
:disclosure-id="`posters-${region.slug}`"
|
||||
@toggle-disclosure="togglePosters(region)"
|
||||
@select="selectRegion(region)"
|
||||
@select-country="(c) => selectCountry(c, region)"
|
||||
>
|
||||
<div class="region-card__header">
|
||||
<h2 class="region-card__title">
|
||||
<button
|
||||
type="button"
|
||||
class="region-card__link"
|
||||
@click="selectRegion(region)"
|
||||
>
|
||||
{{ region.label || 'Other' }}
|
||||
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
|
||||
<use href="#chevron-up" />
|
||||
</svg>
|
||||
</button>
|
||||
</h2>
|
||||
<p class="region-card__summary">{{ summaryLine(region) }}</p>
|
||||
</div>
|
||||
<div class="region-card__body">
|
||||
<ul class="countries-grid">
|
||||
<li v-for="c in region.countries" :key="c.slug">
|
||||
<button
|
||||
type="button"
|
||||
class="country-link"
|
||||
@click="selectCountry(c, region)"
|
||||
>
|
||||
{{ c.name }} <span class="country-link__count">({{ c.count }})</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="region-card__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="region-card__footer-link"
|
||||
@click="selectRegion(region)"
|
||||
>
|
||||
{{ dashboardLink(region) }}
|
||||
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
|
||||
<use href="#chevron-up" />
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<!-- THE POSTER TOGGLE — Driver 2026-09-24: "a toggle or carrot added to the bottom
|
||||
of the region section that says view country posters, which will let you view
|
||||
country posters for each of the countries in the regions". The caret is the
|
||||
same SVG the header and footer links already use, rotated when open, so the
|
||||
control reads as part of this card rather than bolted on. aria-expanded and
|
||||
aria-controls are what make it a real disclosure for a screen reader. -->
|
||||
<p class="region-card__footer region-card__posters-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="region-card__footer-link"
|
||||
:aria-expanded="postersOpen(region) ? 'true' : 'false'"
|
||||
:aria-controls="`posters-${region.slug}`"
|
||||
@click="togglePosters(region)"
|
||||
>
|
||||
{{ postersOpen(region) ? t('Hide country posters') : t('View country posters') }}
|
||||
<svg
|
||||
class="region-card__icon"
|
||||
:class="{ 'region-card__icon--open': postersOpen(region) }"
|
||||
viewBox="0 0 489.67 289.877"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="#chevron-up" />
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<!-- The wall itself: the SHARED component, given only this region's countries. No
|
||||
second level of collapsing (we are already inside one region) and no grouping,
|
||||
hence posterGroupBy returning ''. -->
|
||||
<div v-if="postersOpen(region)" :id="`posters-${region.slug}`" class="region-card__posters">
|
||||
<!-- poster-action="print" — PRINT IN PLACE, not download (Driver card 2026-09-24).
|
||||
PosterWall's posterAction prop defaults to 'download'; UPG-100 passes "print"
|
||||
and the print UX here must be identical. deliverPoster() branches on that one
|
||||
prop and routes BOTH the shipped-.webp and the browser-rasterised path through
|
||||
openPrintFrame, so this is configuration — not a second implementation. -->
|
||||
<!-- ★ TWO PER ROW, AND A KNOB. The Driver, verbatim: "i need two posters per row on
|
||||
the regiosn posters and witha setitn got icnreat eh number of poswers per row".
|
||||
The wall's own default is an auto-fill grid at minmax(210px,1fr), which lays out
|
||||
five across a region card; two is a deliberate, larger card, and the control lets
|
||||
a reader put it back.
|
||||
Both props are OPT-IN on the shared component, so country-posters and
|
||||
my-upg-100-list keep the auto-fill they have always had.
|
||||
The comment sits ABOVE the tag because an HTML comment inside an attribute list is
|
||||
a parse error — which is how the first attempt at this failed. -->
|
||||
<template #extra>
|
||||
<PosterWall
|
||||
:items="postersFor(region)"
|
||||
:group-by="posterGroupBy"
|
||||
|
|
@ -736,8 +519,8 @@ useShadowStyles(
|
|||
poster-action="print"
|
||||
prayer-list-soon-message="Coming soon — a prayer list will be generated for each country, naming every unengaged people group within it."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RegionSectionCard>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env node
|
||||
/* check-regions-uses-library.mjs — `bun run check:regions-uses-library`. Source-only, headless.
|
||||
*
|
||||
* THE DRIVER'S POINT, and the reason this check exists rather than a note: his negotiating position
|
||||
* with the DX-life author is that cloning their /regions page into this bundler MADE ITS COMPONENTS
|
||||
* REUSABLE for every future map plugin. He named the failure mode himself — "a load bearing assumption
|
||||
* that could be proven wrong by going into the private plugin folder for the Regions plugin and seeing
|
||||
* that actually the components are not reusable".
|
||||
*
|
||||
* So this asserts the thing that can be checked by opening the folder: the regions plugin must USE the
|
||||
* shared components, and must NOT hold its own copy of what it uses. A claim about reusability that
|
||||
* only a human can audit is a claim that rots; this one fails on the day someone re-inlines a copy.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
let fails = 0
|
||||
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
|
||||
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
|
||||
|
||||
// area, library file, the plugin file that must consume it, the classes the library file now owns
|
||||
const EXTRACTED = [
|
||||
{
|
||||
lib: 'library/components/regions/RegionSectionCard.vue',
|
||||
consumer: 'plugin-designs/regions-dashboard/regions-list.vue',
|
||||
tag: 'RegionSectionCard',
|
||||
owns: ['region-card', 'countries-grid', 'country-link'],
|
||||
},
|
||||
]
|
||||
|
||||
for (const e of EXTRACTED) {
|
||||
const name = e.lib.split('/').pop()
|
||||
ok(existsSync(resolve(ROOT, e.lib)), `${name} exists in library/`)
|
||||
const plugin = read(e.consumer)
|
||||
ok(plugin.includes(`@map/components/regions/${name}`), `the plugin IMPORTS it from @map/, not a local path`)
|
||||
ok(new RegExp(`<${e.tag}[\\s>]`).test(plugin), `and actually RENDERS <${e.tag}>`)
|
||||
|
||||
// the half that makes it real: the plugin must no longer own what it delegated.
|
||||
const tpl = plugin.slice(plugin.indexOf('<template>'))
|
||||
for (const cls of e.owns) {
|
||||
ok(!new RegExp(`class="[^"]*\\b${cls}\\b`).test(tpl),
|
||||
`the plugin's own template no longer uses .${cls} — the library component owns that markup`)
|
||||
ok(!new RegExp(`'\\.${cls}[{ ]`).test(plugin),
|
||||
`and no longer ships CSS for .${cls} — one copy, in the library component`)
|
||||
}
|
||||
// ...and the library component must actually carry the design, or "reusable" means markup only.
|
||||
const lib = read(e.lib)
|
||||
for (const cls of e.owns) {
|
||||
ok(new RegExp(`'\\.${cls}[{ ]`).test(lib), `${name} DOES carry .${cls} styling, so a consumer gets the look`)
|
||||
}
|
||||
}
|
||||
|
||||
// NEG CONTROLS: each assertion must be able to fail.
|
||||
ok(/class="[^"]*\brl-root\b/.test(read('plugin-designs/regions-dashboard/regions-list.vue')),
|
||||
'NEG CONTROL: the plugin still uses its OWN classes (.rl-root), so the "no longer uses" checks above are not vacuously true')
|
||||
ok(!/RegionSectionCard/.test(read('plugin-designs/regions-dashboard/region-dashboards.vue')),
|
||||
'CONTROL: the OTHER tab does not use the card yet — this check is about what is extracted, not a wish')
|
||||
|
||||
console.log(fails ? `\nREGIONS-USES-LIBRARY: ${fails} FAIL(s)` : '\nREGIONS-USES-LIBRARY: the plugin consumes the shared component and keeps no copy of it')
|
||||
process.exit(fails ? 1 : 0)
|
||||
|
|
@ -274,7 +274,7 @@ function addLayers() {
|
|||
// Add EMPTY first, then setData after the fetch: the addLayer calls below must run
|
||||
// SYNCHRONOUSLY to keep these fills beneath the pins (layer order is insertion order).
|
||||
m.addSource(SRC_COUNTRIES, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
fetch(`${resolveAssetsBase(profileConfig?.value)}/boundaries/world-adm0.geojson`)
|
||||
fetch(`${resolveAssetsBase(profileConfig?.value)}/boundaries/world-adm0-web.geojson`)
|
||||
.then((r) => r.json())
|
||||
.then((gj) => {
|
||||
for (const f of gj.features || []) {
|
||||
|
|
|
|||
|
|
@ -834,7 +834,7 @@ function addLayers() {
|
|||
// keep this layer underneath the pins (layer order is insertion order). An awaited fetch
|
||||
// here would put the choropleth on TOP of every bubble.
|
||||
m.addSource('rd-countries', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0.geojson`)
|
||||
fetch(`${resolveAssetsBase(profileConfig.value)}/boundaries/world-adm0-web.geojson`)
|
||||
.then((r) => r.json())
|
||||
.then((gj) => {
|
||||
for (const f of gj.features || []) {
|
||||
|
|
|
|||
|
|
@ -146,8 +146,8 @@ useShadowStyles(`
|
|||
<input class="cpt-search" type="search" :value="store.search.value" @input="onSearch"
|
||||
:placeholder="$t('neighbor.searchCockpit')" :aria-label="$t('neighbor.search')" />
|
||||
<span class="cpt-counter">
|
||||
<b>{{ store.counts.value.senders.toLocaleString() }}</b> senders →
|
||||
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> clusters · {{ store.counts.value.hidden }} hidden
|
||||
<b>{{ store.counts.value.senders.toLocaleString() }}</b> {{ $t('neighbor.cockpit.senders') }}
|
||||
<b>{{ store.counts.value.clusters.toLocaleString() }}</b> {{ $t('neighbor.cockpit.clustersHidden', { n: store.counts.value.hidden }) }}
|
||||
</span>
|
||||
<span class="cpt-prov" :class="{ err: store.provenance.value.apiError }"
|
||||
:title="store.provenance.value.reachedFilter">
|
||||
|
|
@ -163,8 +163,8 @@ useShadowStyles(`
|
|||
<div class="cpt-cell map"><MapCell /></div>
|
||||
<div class="cpt-cell flower"><FlowerCell /></div>
|
||||
<div class="cpt-seg" role="tablist" :aria-label="$t('neighbor.cockpit.viewSwitch')">
|
||||
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">MAP</button>
|
||||
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">FLOWER</button>
|
||||
<button :class="{ on: store.mode.value === 'map' }" @click="setMode('map')">{{ $t('neighbor.cockpit.map') }}</button>
|
||||
<button :class="{ on: store.mode.value === 'flower' }" @click="setMode('flower')">{{ $t('neighbor.cockpit.flower') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -177,13 +177,13 @@ useShadowStyles(`
|
|||
<!-- LEGEND RIBBON (one shared key for all four cells) -->
|
||||
<div class="cpt-legend">
|
||||
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.RED }"></span> {{ $t('neighbor.cockpit.unreachedTarget') }}</span>
|
||||
<span class="cpt-lg">reached %
|
||||
<span class="cpt-lg">{{ $t('neighbor.strip.pctReached') }}
|
||||
<span class="cpt-ramp">
|
||||
<i v-for="(g, i) in COLORS.GREEN_BINS" :key="i" :style="{ background: g }"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> SEND (solid=same-lang · dashed=cluster · dotted=in-country)</span>
|
||||
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> selection · pop = log radius · Luke 10:2</span>
|
||||
<span class="cpt-lg"><span class="cpt-line" :style="{ borderColor: COLORS.NAVY }"></span> {{ $t('neighbor.cockpit.legendSend') }}</span>
|
||||
<span class="cpt-lg"><span class="cpt-sw" :style="{ background: COLORS.GOLD }"></span> {{ $t('neighbor.cockpit.legendSelection') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- loading / error overlay -->
|
||||
|
|
|
|||
|
|
@ -395,8 +395,8 @@ useShadowStyles(
|
|||
|
||||
<!-- ring legend -->
|
||||
<div v-if="placed" class="cf-rings">
|
||||
<b>inner</b> same-country · <b>petals</b> cross-border<br>
|
||||
navy = SEND · <span :style="{ color: COLORS.GOLD }">gold</span> = same-language mesh
|
||||
{{ $t('neighbor.fm.whorl') }}<br>
|
||||
{{ $t('neighbor.cockpit.navySend') }} · <span :style="{ color: COLORS.GOLD }">{{ $t('neighbor.cockpit.goldMesh') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -328,9 +328,9 @@ useShadowStyles(`
|
|||
`, 'flower-clusters')
|
||||
|
||||
const MODES = [
|
||||
{ key: 'family', label: 'Family', sz: 'fewer · bigger' },
|
||||
{ key: 'language', label: 'Language', sz: 'more' },
|
||||
{ key: 'dialect', label: 'Dialect', sz: 'most' }
|
||||
{ key: 'family', tKey: 'neighbor.fc.mode.family', szKey: 'neighbor.fc.size.fewerBigger' },
|
||||
{ key: 'language', tKey: 'neighbor.fc.mode.language', szKey: 'neighbor.fc.size.more' },
|
||||
{ key: 'dialect', tKey: 'neighbor.fc.mode.dialect', szKey: 'neighbor.fc.size.most' }
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
@ -343,9 +343,9 @@ const MODES = [
|
|||
<div class="fc-modes" role="tablist" :aria-label="$t('neighbor.fc.clusterBy')">
|
||||
<button v-for="md in MODES" :key="md.key" class="fc-mode" :class="{ on: mode === md.key }"
|
||||
role="tab" :aria-selected="mode === md.key" @click="setMode(md.key)">
|
||||
<span class="fc-circle">{{ md.label.slice(0,3).toUpperCase() }}</span>
|
||||
<span class="fc-mode-lbl">{{ md.label }}</span>
|
||||
<span class="fc-mode-sz">{{ md.sz }}</span>
|
||||
<span class="fc-circle">{{ md.key.slice(0,3).toUpperCase() }}</span>
|
||||
<span class="fc-mode-lbl">{{ $t(md.tKey) }}</span>
|
||||
<span class="fc-mode-sz">{{ $t(md.szKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -63,12 +63,12 @@ const hiddenCount = computed(() => counts.value.hidden || 0)
|
|||
|
||||
// ── sort options ──────────────────────────────────────────────────────────────
|
||||
const SORTS = [
|
||||
{ key: 'mobilization', label: 'Mobilization' },
|
||||
{ key: 'pct', label: '%Christian' },
|
||||
{ key: 'pop', label: 'Population' },
|
||||
{ key: 'bridge', label: 'Bridge' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'frontier', label: 'Frontier first' }
|
||||
{ key: 'mobilization', tKey: 'neighbor.list.sortMobilization' },
|
||||
{ key: 'pct', tKey: 'neighbor.list.colPctChristian' },
|
||||
{ key: 'pop', tKey: 'neighbor.list.colPopulation' },
|
||||
{ key: 'bridge', tKey: 'neighbor.strip.bridgeType' },
|
||||
{ key: 'name', tKey: 'neighbor.list.colName' },
|
||||
{ key: 'frontier', tKey: 'neighbor.list.sortFrontier' }
|
||||
]
|
||||
function pickSort(key) { store.setSort(key) }
|
||||
|
||||
|
|
@ -303,7 +303,7 @@ useShadowStyles(`
|
|||
:class="{ active: sortKey === s.key, frontier: s.key === 'frontier' }"
|
||||
type="button"
|
||||
@click="pickSort(s.key)"
|
||||
>{{ s.label }}</button>
|
||||
>{{ $t(s.tKey) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -363,7 +363,7 @@ useShadowStyles(`
|
|||
class="cpt-l-info"
|
||||
:class="{ on: pinnedInfo === r.id }"
|
||||
type="button"
|
||||
title="Details"
|
||||
:title="$t('neighbor.cockpit.details')"
|
||||
@click.stop="toggleInfo(r.id)"
|
||||
>i</button>
|
||||
</div>
|
||||
|
|
@ -374,12 +374,12 @@ useShadowStyles(`
|
|||
<dt>{{ $t('neighbor.list.colPeopleGroup') }}</dt><dd>{{ r.name }}</dd>
|
||||
<dt>{{ $t('neighbor.list.colCountry') }}</dt><dd>{{ r.country }}</dd>
|
||||
<dt>{{ $t('neighbor.list.colLanguage') }}</dt><dd>{{ r.lang }}<span v-if="r.rol3"> · {{ r.rol3 }}</span></dd>
|
||||
<dt>%Christian</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
|
||||
<dt>{{ $t('neighbor.list.colPctChristian') }}</dt><dd>{{ pctLabel(r.pct) }} · {{ binLabel(r.pct) }}</dd>
|
||||
<dt>{{ $t('neighbor.list.colPopulation') }}</dt><dd>{{ fmtPop(r.pop) }}</dd>
|
||||
<dt>Bridge</dt><dd>{{ r.bridge }}</dd>
|
||||
<dt>Adjacency</dt><dd>{{ r.adjacency }}</dd>
|
||||
<dt>{{ $t('neighbor.strip.bridgeType') }}</dt><dd>{{ r.bridge }}</dd>
|
||||
<dt>{{ $t('neighbor.list.colAdjacency') }}</dt><dd>{{ r.adjacency }}</dd>
|
||||
<dt>{{ $t('neighbor.list.colReligion') }}</dt><dd>{{ r.religion_bg || r.religionFamily }}</dd>
|
||||
<dt v-if="r.code">Code</dt><dd v-if="r.code">{{ r.code }}</dd>
|
||||
<dt v-if="r.code">{{ $t('neighbor.list.colCode') }}</dt><dd v-if="r.code">{{ r.code }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
|
||||
<script src="../all-people-groups.js" integrity="sha384-JoGdbQlwjgq9+dOQy3eDJ1DjLwyZ6VbclEci++67/f8faF0HOa1ox10mkNn1fj73" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-3rrykWo456uGE2YmJ/i7AeehqxCWQe0bVDjDOj5VEwaPZV5pFw4iegauoNaZMyDR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
36
public/dx-maps/assets/boundaries/gb-IND-ADM1-web.geojson
Normal file
36
public/dx-maps/assets/boundaries/gb-IND-ADM1-web.geojson
Normal file
File diff suppressed because one or more lines are too long
164
public/dx-maps/assets/boundaries/world-adm0-web.geojson
Normal file
164
public/dx-maps/assets/boundaries/world-adm0-web.geojson
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,7 @@
|
|||
"meta": {
|
||||
"title": "Messaging & App Channels",
|
||||
"grain": "one row per country x communication app",
|
||||
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.",
|
||||
"why": "Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp (Messenger or Business) is the top messaging app in 73 countries, Telegram in 38 — neither appears in referral share at all.",
|
||||
"ios_caveat": "iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.",
|
||||
"ranking_caveat": "A top-free RANK is most-DOWNLOADED recently, not most-used.",
|
||||
"classification_caveat": "kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.",
|
||||
|
|
@ -2830,19 +2830,6 @@
|
|||
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
|
||||
"source": "Apple App Store RSS — top free apps by country"
|
||||
},
|
||||
{
|
||||
"country": "Thailand",
|
||||
"iso3": "THA",
|
||||
"region": "Asia",
|
||||
"app": "LINE MAN: Food Delivery & more",
|
||||
"kind": "messaging",
|
||||
"rank": 13,
|
||||
"is_top_messaging": true,
|
||||
"people_groups": 8,
|
||||
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
|
||||
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
|
||||
"source": "Apple App Store RSS — top free apps by country"
|
||||
},
|
||||
{
|
||||
"country": "Burkina Faso",
|
||||
"iso3": "BFA",
|
||||
|
|
@ -3006,7 +2993,7 @@
|
|||
"app": "WhatsApp Messenger",
|
||||
"kind": "messaging",
|
||||
"rank": 37,
|
||||
"is_top_messaging": false,
|
||||
"is_top_messaging": true,
|
||||
"people_groups": 8,
|
||||
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
|
||||
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
|
||||
|
|
@ -7809,19 +7796,6 @@
|
|||
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
|
||||
"source": "Apple App Store RSS — top free apps by country"
|
||||
},
|
||||
{
|
||||
"country": "Maldives",
|
||||
"iso3": "MDV",
|
||||
"region": "Asia",
|
||||
"app": "Malé Taxi Line",
|
||||
"kind": "messaging",
|
||||
"rank": 1,
|
||||
"is_top_messaging": true,
|
||||
"people_groups": 1,
|
||||
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
|
||||
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
|
||||
"source": "Apple App Store RSS — top free apps by country"
|
||||
},
|
||||
{
|
||||
"country": "Northern Mariana Islands",
|
||||
"iso3": "MNP",
|
||||
|
|
@ -13120,7 +13094,7 @@
|
|||
"app": "WhatsApp Messenger",
|
||||
"kind": "messaging",
|
||||
"rank": 33,
|
||||
"is_top_messaging": false,
|
||||
"is_top_messaging": true,
|
||||
"people_groups": 1,
|
||||
"platform_bias": "iOS only — most of these countries are Android-majority; absence here is not absence in-country",
|
||||
"classification": "name-keyword HEURISTIC, not a verified taxonomy",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-Yyfio5ozzXMi/atFe+yX9p275iNd8nHRt72eiac+wqcE7pDa4ZJdOWPdb69snAkV" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-xwzODwX387yfNONPEKpKzvyroMMl53Q2sNDSad+GSend5mDN4FG4BWR0OIQNjdty" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-xwzODwX387yfNONPEKpKzvyroMMl53Q2sNDSad+GSend5mDN4FG4BWR0OIQNjdty" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-xwzODwX387yfNONPEKpKzvyroMMl53Q2sNDSad+GSend5mDN4FG4BWR0OIQNjdty" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-sJSAqY3fTSJPU1Bh06c1uEMUr/CWR6qMbtJ5UgCjVc7nd0W+pNXJcvYy80GzlLsS" crossorigin="anonymous"></script>
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-sJSAqY3fTSJPU1Bh06c1uEMUr/CWR6qMbtJ5UgCjVc7nd0W+pNXJcvYy80GzlLsS" crossorigin="anonymous"></script>
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue