doxa-maps/plugin-designs/all-people-groups/all-people-groups.vue
Doxa Maps c08421f886
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source a1c8f3f6
2026-09-27 02:57:03 +00:00

422 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* all-people-groups.vue — every people group on earth, around the DOXA set.
*
* @description All 12,379 people groups (peoplegroups.org) in four colors — reached,
* unreached, frontier — with the DOXA groups in black at the center of the story.
* @profile all-people-groups
* @bundle all-people-groups
* @element all-people-groups
*
* THE PICTURE THIS MAP PAINTS: the DOXA assignment (black) sits inside the whole
* remaining task. Green shows where the church already is (the senders next
* door); red shows where nothing has started. A mobilizer reads it in one look:
* "who near this black dot could go?"
*
* CATEGORIES (from peoplegroups.org GSEC — Global Status of Evangelical Christianity):
* BLACK doxa — the DOXA people groups (live match vs the prayer API by ROP3)
* RED frontier — GSEC 0–1: no evangelicals, or <2% with NO church planting
* YELLOW unreached — GSEC 2–3: <2% evangelical, active church-planting work
* GREEN reached — GSEC 4–6: 2%+ evangelical
*
* DATA: assets/people-groups/people-groups-points.json — the public peoplegroups.org rows (one per
* group×country) cut to the ten fields this map uses, with repeated strings in dictionaries
* (library/scripts/people-groups/build-points.mjs; 0.74 MB instead of the 7.5 MB slim file) — plus
* the live DOXA set via the standard pray-tools pipeline.
*
* ★ LOAD ORDER (2026-09-25, the agent: "not even loading, takes a very long time"). Pins used to
* wait for BOTH the 7.5 MB file AND the live prayer API before anything drew. Now the compact
* file alone draws every pin by GSEC, and the DOXA overlay recolours its pins black when the API
* answers. A slow or unreachable API costs the black layer, never the map.
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useMapInstance, engineGlobal } from '@map/composables/useMapInstance.js'
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
import { useMapData } from '@map/composables/useMapData.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
useShadowStyles(`
.apg-root { position:absolute;inset:0;overflow:hidden;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; }
.apg-canvas { position:absolute;inset:0; }
/* MATERIAL LIGHT (2026-09-21). Was background #0b1220 / color #9aa7b8 — a near-black overlay that
every visitor sees, because v-if="!appReady" renders it on EVERY load until the data arrives.
Same shape as the .dsm-loading violation already fixed in doxa-simple-map: an UNCONDITIONAL
dark surface, not one of the dormant .is-dark rulesets. The earlier sweep found six dark
matches and correctly ruled five of them unreachable, but this file was not among them.
Palette: surface #f3f3f1, muted text #6d766e. */
.apg-loading { position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:#f3f3f1;color:#6d766e;font-size:14px;z-index:5; }
.apg-error { position:absolute;top:50%;left:12px;right:12px;z-index:7;transform:translateY(-50%);
text-align:center;font-size:13px;line-height:1.5;color:#7f1d1d;background:rgba(255,255,255,.94);
border:1px solid rgba(127,29,29,.25);border-radius:10px;padding:12px 14px; }
.apg-legend { position:absolute;left:10px;top:10px;z-index:6;background:rgba(255,255,255,.95);
border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 3px 12px rgba(0,0,0,.15);
padding:12px 14px;min-width:230px; }
.apg-legend-title { font-size:12px;font-weight:800;color:#111827;margin-bottom:2px;
text-transform:uppercase;letter-spacing:.05em; }
.apg-legend-sub { font-size:11px;color:#6b7280;margin-bottom:8px; }
.apg-row { display:flex;align-items:center;gap:9px;font-size:12.5px;color:#374151;
line-height:2;cursor:pointer;border-radius:8px;padding:0 8px;margin:0 -8px;
border:1px solid transparent; }
.apg-row:hover { background:#f3f4f6; }
.apg-row.active { background:#eef2ff;border-color:#c7d2fe;font-weight:700; }
.apg-dot { flex:none;width:11px;height:11px;border-radius:50%;border:1.5px solid #fff;
box-shadow:0 0 0 1px rgba(0,0,0,.35); }
.apg-count { margin-left:auto;font-variant-numeric:tabular-nums;color:#6b7280;font-size:11.5px; }
.apg-hint { font-size:10.5px;color:#9ca3af;margin-top:7px; }
.apg-pop { font:12.5px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.apg-pop b { font-size:13.5px; }
.apg-pop .cat { display:inline-block;padding:1px 8px;border-radius:999px;color:#fff;
font-size:10.5px;font-weight:700;letter-spacing:.03em;vertical-align:1px; }
.apg-pop .meta { color:#4b5563;margin-top:4px; }
.apg-pop a { display:inline-block;margin-top:7px;font-weight:700;color:#b45309;text-decoration:none; }
.apg-pop a:hover { text-decoration:underline; }
`, 'all-people-groups')
// ─── Config from ProfileLoader ───────────────────────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const dataSource = inject('dataSource')
const dataStore = inject('dataStore')
const mapStore = inject('mapStore')
// Read-only theme flag, the same source gospel-resources reads. No ThemeToggleButton:
// this profile does not own its theme, it follows the host page.
const isDark = computed(() => profileConfig.value?.isDark === true)
// This map's own standalone embed page, TREE-RELATIVE (<bundle>/<profile>/index.html),
// taken from the manifest entry rather than hand-composed. ShareButton joins it to the
// served tree root via utils/embedBase.js, so the link works wherever the tree is hosted
// — never a hardcoded /js/doxa-maps/…, which 404s on doxa.life's /js/doxa-maps-build/.
const SHARE_EMBED_PATH = 'all-people-groups/all-people-groups/index.html'
const mapProvider = resolveMapProvider(profileConfig)
// Through the shared resolver, not a '/assets' fallback: that root path 404s whenever the tree
// is served below the site root, and inside the Research Gallery no assetsBase is passed at all.
const assetsBase = computed(() => resolveAssetsBase(profileConfig?.value || {}))
// ─── Words (library/i18n/locales/<lc>/allPeopleGroups.json, all 11 locales) ────
// The English for these eleven strings lives in library/i18n/locales/en/allPeopleGroups.json and
// nowhere else. An inline EN map used to duplicate it here as the bare-host fallback; with the
// locale-honouring default that copy had no job left, and a second copy of a catalogue is only a
// place for the two to drift apart.
// C4 wave 3d: the guard is library/composables/useOptionalI18n now — this site needs more than `t`,
// so it takes the {t, te, locale} form rather than useOptionalT.
// ★ THE LOCALE-HONOURING FALLBACK, per dx-shepherd's ruling (2026-09-27) and the agent's goal (d):
// a BARE-HOST EMBED WITH ?lang=ar MUST RENDER ARABIC. Recorded in COMMON.md as the embed contract.
// This file used to pass its inline EN map as the fallback, which served ENGLISH to exactly the hosts
// that cannot install vue-i18n — the seven bundles here that install none. tStatic honours the runtime
// locale and keeps English as the last resort, so those readers now get their own language.
// ★ THE RULING'S CONDITION WAS MEASURED BEFORE THE SWITCH, not after: all ELEVEN keys this file uses
// are present in all ELEVEN locales (0 gaps, counted 2026-09-27), and ar differs from en on 11 of 11,
// so the change is observable rather than cosmetic. With the catalogue complete there is nothing the
// EN map could still rescue: a key it holds is a key tStatic also holds.
// The keys arrive BARE here ('hint', 'pray'), and qualify() adds the 'allPeopleGroups.' prefix, which
// is exactly what the hand-written `const full = 'allPeopleGroups.' + key` did.
// ★ ONE EQUIVALENCE WORTH STATING: the old code asked te() first and only then t(). The helper calls
// t() and falls back when it returns the key unchanged. Same outcome both ways — present key: the
// translation; absent key: EN — and it no longer depends on te() being implemented.
const { t: tr, locale: i18nLocale } = useOptionalI18n('allPeopleGroups')
// locale arrives as a REF when the host installed i18n and a plain string when it did not
// (PosterWall.vue records the same variance), so unwrap both shapes.
const uiLocale = computed(() => {
const l = i18nLocale
return (l && typeof l === 'object' && 'value' in l ? l.value : l) || 'en'
})
const fmt = (n) => { try { return Number(n).toLocaleString(uiLocale.value) } catch (_) { return Number(n).toLocaleString() } }
// ─── The four categories (the agent's palette) ──────────────────────────────
// Tuned for the LIGHT basemap (2026-09-25; was a near-black map, where the black DOXA pins vanished).
// Same four meanings and hues; yellow and green are a step darker because pure #f1c40f / #2ecc71
// wash out on near-white, and every pin carries a thin dark ring instead of a white one.
const CATS = [
{ id: 'doxa', color: '#111827' },
{ id: 'frontier', color: '#e0392b' },
{ id: 'unreached', color: '#e6a800' },
{ id: 'reached', color: '#23a45a' },
]
const CAT_COLOR = Object.fromEntries(CATS.map(c => [c.id, c.color]))
const counts = ref({ doxa: 0, frontier: 0, unreached: 0, reached: 0 })
// The DOXA count is only known once the prayer API answers; until then its row says "…", not 0.
const doxaReady = ref(false)
function categorize(row, doxaSet) {
if (doxaSet.has(String(row.ROP3Code || ''))) return 'doxa'
const g = Number(row.GSEC)
if (!Number.isFinite(g) || g <= 1) return 'frontier'
if (g <= 3) return 'unreached'
return 'reached'
}
// ─── Outbound attribution (same ladder as PeopleGroupDetail) ─────────────────
function utmSource() {
let fromUrl = ''
try { fromUrl = new URLSearchParams(window.location.search).get('utm_source') || '' } catch (_) {}
const fromConfig = profileConfig?.value?.utmSource
return encodeURIComponent(String(fromUrl || fromConfig || 'agwm'))
}
// ─── Map boot (shared composable — provider-aware, token-aware) ──────────────
const mapContainer = ref(null)
const appReady = ref(false)
const mapError = ref(null)
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
provider: mapProvider,
// Named style 'paper' = near-white minimal (Mapbox light-v11 / MapLibre positron): house style is
// Material LIGHT, and the black DOXA pins must read on it. A host may still pass its own style.
style: profileConfig?.value?.style ?? 'paper',
center: [20, 12],
zoom: 1.6,
})
// DOXA set via the standard pipeline (rop3 = peoplegroups.org ROP3Code)
const dsm = new DataSourceManager()
const mapData = useMapData({
mapId: 'all-people-groups',
dataSourceId: dataSource.value,
dataSourceManager: dsm,
dataStore,
markRaw: (v) => v,
})
let featuresAll = [] // kept for legend clicks (filter + fitBounds)
let doxaBySlugRop = new Map() // rop3 → { slug, name } for the pray link
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
// Rows in one shape whichever file served them: the compact points file (column arrays + string
// dictionaries), or — for a tree built before it existed — the old slim file's objects.
async function loadRows() {
const base = assetsBase.value
const r = await fetch(`${base}/people-groups/people-groups-points.json`)
if (r.ok) {
const d = await r.json()
const D = d.dicts || {}
return (d.rows || []).map((x) => ({
lng: x[0], lat: x[1], gsec: x[2], rop3: x[3], name: x[4], country: (D.country || [])[x[5]] || '',
population: x[6], language: (D.language || [])[x[7]] || '', religion: (D.religion || [])[x[8]] || '',
evangelical: (D.evangelical || [])[x[9]] || '',
}))
}
const old = await fetch(`${base}/people-groups/people-groups-slim.json`)
if (!old.ok) throw new Error(`people-groups-points.json ${r.status}`)
const slim = await old.json()
return (slim.rows || []).map((row) => ({
lng: Number(row.Long), lat: Number(row.Lat), gsec: row.GSEC, rop3: String(row.ROP3Code || ''),
name: row.Name || '', country: row.CountryName || '', population: Number(row.Population) || 0,
language: row.LanguageName || '', religion: row.ReligionName || '', evangelical: row.EvangelicalLevel || '',
}))
}
// Every pin by GSEC, from the file alone — the DOXA overlay (applyDoxa) comes later and never blocks.
function buildFeatures(rows, doxaSet) {
const feats = []
const c = { doxa: 0, frontier: 0, unreached: 0, reached: 0 }
for (const row of rows) {
const lat = Number(row.lat), lng = Number(row.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue
const cat = categorize({ ROP3Code: row.rop3, GSEC: row.gsec }, doxaSet)
c[cat]++
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: {
name: row.name, country: row.country, population: Number(row.population) || 0,
gsec: row.gsec, evangelical: row.evangelical, language: row.language, religion: row.religion,
rop3: String(row.rop3 || ''), category: cat,
},
})
}
counts.value = c
featuresAll = feats
return { type: 'FeatureCollection', features: feats }
}
// The live DOXA set (rop3 = peoplegroups.org ROP3Code). Resolves to an empty set on failure.
async function loadDoxaSet() {
let doxa = []
try { await mapData.loadData(); doxa = mapData.normalizedPeopleGroups.value || [] } catch (e) {
console.warn('[all-people-groups] DOXA overlay unavailable (map still renders by GSEC):', e)
}
const doxaSet = new Set()
for (const pg of doxa) {
// The prayer API's ROP3 is imb_reg_of_people_3, which sources.json normalises to
// peopleGroupCountry — the API source maps NO peopleGroupId (only the CSV source does). Reading
// peopleGroupId alone left this set empty and the legend read "DOXA people groups 0".
const rop = String(pg.peopleGroupId || pg.rop3 || pg.peopleGroupCountry || '')
if (!rop) continue
doxaSet.add(rop)
if (!doxaBySlugRop.has(rop)) doxaBySlugRop.set(rop, { slug: pg.slug || '', name: pg.name || '' })
}
return doxaSet
}
function addLayer(fc) {
const m = map.value
m.addSource('apg', { type: 'geojson', data: fc })
m.addLayer({
id: 'apg-pins', type: 'circle', source: 'apg',
// ★ circle-sort-key IS A LAYOUT PROPERTY, NOT PAINT — and putting it in paint cost this
// layer entirely under MapLibre. Mapbox tolerated the misplacement; MapLibre validates the
// spec and rejects the WHOLE layer for one unknown paint property, so every pin on this map
// vanished with nothing thrown. FOUND by listening to the map's own `error` event across
// every isMap leaf, which is the only place it surfaces:
// layers.apg-pins.paint.circle-sort-key: unknown property "circle-sort-key"
// Same failure shape as the *-emissive-strength drops fixed alongside this; the sort order
// itself is unchanged, it just now lives where the style spec says it belongs.
layout: {
// DOXA above frontier above unreached above reached — the assignment stays visible.
'circle-sort-key': ['match', ['get', 'category'], 'doxa', 4, 'frontier', 3, 'unreached', 2, 1],
},
paint: {
'circle-color': ['match', ['get', 'category'],
'doxa', CAT_COLOR.doxa, 'frontier', CAT_COLOR.frontier,
'unreached', CAT_COLOR.unreached, CAT_COLOR.reached],
// Thin high-contrast ring — visible, never clunky (the pin-visibility spec).
// A thin DARK ring on the light basemap: separates overlapping pins and keeps the yellow and
// green legible on near-white (a white ring was right only on the old dark map).
'circle-stroke-color': '#1f2937',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 0.3, 6, 0.6, 12, 1],
'circle-stroke-opacity': 0.55,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 2.2, 4, 3.4, 8, 6, 12, 9],
'circle-opacity': 0.92,
},
})
m.on('mouseenter', 'apg-pins', () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', 'apg-pins', () => { m.getCanvas().style.cursor = '' })
m.on('click', 'apg-pins', (e) => {
const f = e.features && e.features[0]
if (!f) return
const p = f.properties
const doxaRec = p.category === 'doxa' ? doxaBySlugRop.get(p.rop3) : null
const cat = CATS.find(x => x.id === p.category)
const pray = doxaRec && doxaRec.slug
? `<a href="https://pray.doxa.life/${encodeURIComponent(doxaRec.slug)}?source=doxalife&utm_source=${utmSource()}" target="_blank" rel="noopener">${esc(tr('pray'))}</a>`
: ''
// C3: engineGlobal() resolves a provider NAME to its namespace and already defaults to Mapbox
// for anything unrecognised, which is exactly what this ternary's else branch did.
const NS = engineGlobal(mapProvider)
new NS.Popup({ closeButton: true, maxWidth: '290px' })
.setLngLat(e.lngLat)
.setHTML(`<div class="apg-pop">
<b>${esc(p.name)}</b> <span class="cat" style="background:${cat.color}">${esc(tr(cat.id).split(' — ')[0])}</span>
<div class="meta">${esc(p.country)} · ${esc(tr('population', { n: fmt(p.population) }))}</div>
<div class="meta">${esc(p.evangelical || ('GSEC ' + p.gsec))}</div>
<div class="meta">${esc(p.language)}${p.religion ? ' · ' + esc(p.religion) : ''}</div>
${pray}
</div>`)
.addTo(m)
})
}
// ─── Legend interactions: click a category → filter + fit to ALL of it ───────
const activeCat = ref(null)
function selectCat(id) {
const m = map.value
if (!m || !m.getLayer('apg-pins')) return
if (activeCat.value === id) { // click again = show everything
activeCat.value = null
m.setFilter('apg-pins', null)
m.fitBounds([[-170, -55], [179, 72]], { padding: 20, duration: 900 })
return
}
activeCat.value = id
m.setFilter('apg-pins', ['==', ['get', 'category'], id])
// Zoom OUT to see every dot of the chosen category (the visibility spec).
let minX = 180, minY = 85, maxX = -180, maxY = -85, n = 0
for (const f of featuresAll) {
if (f.properties.category !== id) continue
const [x, y] = f.geometry.coordinates
if (x < minX) minX = x; if (x > maxX) maxX = x
if (y < minY) minY = y; if (y > maxY) maxY = y
n++
}
if (n) m.fitBounds([[minX, minY], [maxX, maxY]], { padding: 60, duration: 900, maxZoom: 6 })
}
onMounted(async () => {
// Start both fetches NOW, in parallel with the map booting; neither waits for the other.
const rowsPromise = loadRows().catch((err) => { mapError.value = String(err?.message || err); return null })
const doxaPromise = loadDoxaSet()
try { await initializeMap() } catch (err) {
mapError.value = String(err?.message || err); return
}
// No map: if the pane is still here that is a real failure (token, container) and says so; if it
// was unmounted while initialising, useMapInstance returned quietly and so do we.
if (!map.value) { if (mapContainer.value) mapError.value = 'Map failed to initialize — check token and container'; return }
map.value.once('style.load', async () => {
try {
const rows = await rowsPromise
if (!rows || !map.value) return
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
addLayer(buildFeatures(rows, new Set())) // every pin, by GSEC — the first paint
mapStore?.registerMap?.('all-people-groups', map.value)
appReady.value = true
// DOXA overlay: recolour when the prayer API answers, whenever that is.
const doxaSet = await doxaPromise
if (doxaSet.size && map.value && map.value.getSource('apg')) map.value.getSource('apg').setData(buildFeatures(rows, doxaSet))
doxaReady.value = true
} catch (err) { mapError.value = String(err?.message || err) }
})
})
onBeforeUnmount(() => {
mapStore?.unregisterMap?.('all-people-groups')
destroy()
})
const totalShown = computed(() =>
counts.value.doxa + counts.value.frontier + counts.value.unreached + counts.value.reached)
</script>
<template>
<div class="apg-root">
<div v-if="!appReady && !mapError" class="apg-loading">{{ tr('loading') }}</div>
<div v-if="mapError" class="apg-error">{{ tr('loadError') }} {{ mapError }}</div>
<div ref="mapContainer" class="apg-canvas" />
<!-- SHARING. This bundle was published, embeddable and reachable with NO way to
share it by any mechanism — no ShareButton, no shareUrl config, and no composite
parent overriding one on its behalf, while every other published map had exactly
one (check:share-policy recorded it as 'debt-no-share'). A reader who landed here
could not send it to anyone, which is the point of an embeddable jamstack map.
NOT gated on appReady, unlike the legend below. Sharing a map does not require
its data to have loaded — and gating it would remove the affordance exactly when
a slow or failed load makes a reader most likely to want the link. Same call
gospel-resources records: "ShareButton needs neither and always renders so the
map is embeddable."
has-geocoder=false: this profile mounts no search pill, so the toolbar's mobile
top offset collapses to the desktop 10px instead of reserving space for one. -->
<MapToolbar :has-geocoder="false">
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
</MapToolbar>
<div v-if="appReady" class="apg-legend">
<div class="apg-legend-title">{{ tr('title') }}</div>
<div class="apg-legend-sub">{{ tr('locations', { n: fmt(totalShown) }) }}</div>
<div
v-for="c in CATS" :key="c.id"
class="apg-row" :class="{ active: activeCat === c.id }"
@click="selectCat(c.id)"
>
<span class="apg-dot" :style="{ background: c.color }"></span>
{{ tr(c.id) }}
<span class="apg-count">{{ c.id === 'doxa' && !doxaReady ? '…' : fmt(counts[c.id] || 0) }}</span>
</div>
<div class="apg-hint">{{ tr('hint') }}</div>
</div>
</div>
</template>