doxa-maps/plugin-designs/all-people-groups/all-people-groups.vue

297 lines
13 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: the shared static dataset assets/people-groups/people-groups-slim.json
* (built from the public peoplegroups.org CSV — one row per group×country) + the
* live DOXA set via the standard pray-tools pipeline. If the API is unreachable
* the map still renders fully by GSEC — the black layer just stays empty.
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { resolveMapProvider } 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'
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; }
.apg-loading { position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:#0b1220;color:#9aa7b8;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')
const mapProvider = resolveMapProvider(profileConfig)
const assetsBase = computed(() =>
String(profileConfig?.value?.assetsBase || '/assets').replace(/\/+$/, ''))
// ─── The four categories (the Driver's palette) ──────────────────────────────
const CATS = [
{ id: 'doxa', label: 'DOXA people groups', color: '#111827' },
{ id: 'frontier', label: 'Frontier — nothing started', color: '#e74c3c' },
{ id: 'unreached', label: 'Unreached — work beginning', color: '#f1c40f' },
{ id: 'reached', label: 'Reached — church is there', color: '#2ecc71' },
]
const CAT_COLOR = Object.fromEntries(CATS.map(c => [c.id, c.color]))
const counts = ref({ doxa: 0, frontier: 0, unreached: 0, reached: 0 })
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,
style: profileConfig?.value?.style ?? 'ink', // named style: near-black, max pin contrast
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]))
}
async function buildFeatures() {
const [slim, doxa] = await Promise.all([
fetch(`${assetsBase.value}/people-groups/people-groups-slim.json`).then(r => {
if (!r.ok) throw new Error(`people-groups-slim.json ${r.status}`)
return r.json()
}),
mapData.loadData().then(() => mapData.normalizedPeopleGroups.value || []).catch((e) => {
console.warn('[all-people-groups] DOXA overlay unavailable (map still renders by GSEC):', e)
return []
}),
])
const doxaSet = new Set()
for (const pg of doxa) {
const rop = String(pg.peopleGroupId || pg.rop3 || '')
if (!rop) continue
doxaSet.add(rop)
if (!doxaBySlugRop.has(rop)) doxaBySlugRop.set(rop, { slug: pg.slug || '', name: pg.name || '' })
}
const feats = []
const c = { doxa: 0, frontier: 0, unreached: 0, reached: 0 }
for (const row of (slim.rows || [])) {
const lat = Number(row.Lat), lng = Number(row.Long)
if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue
const cat = categorize(row, doxaSet)
c[cat]++
feats.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: {
name: row.Name || '', country: row.CountryName || '',
population: Number(row.Population) || 0,
gsec: row.GSEC, evangelical: row.EvangelicalLevel || '',
language: row.LanguageName || '', religion: row.ReligionName || '',
rop3: String(row.ROP3Code || ''), category: cat,
},
})
}
counts.value = c
featuresAll = feats
return { type: 'FeatureCollection', features: feats }
}
function addLayer(fc) {
const m = map.value
m.addSource('apg', { type: 'geojson', data: fc })
m.addLayer({
id: 'apg-pins', type: 'circle', source: 'apg',
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).
'circle-stroke-color': '#ffffff',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 0.4, 6, 0.8, 12, 1.1],
'circle-stroke-opacity': 0.85,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 2.2, 4, 3.4, 8, 6, 12, 9],
'circle-opacity': 0.92,
// DOXA above frontier above unreached above reached — the assignment stays visible.
'circle-sort-key': ['match', ['get', 'category'], 'doxa', 4, 'frontier', 3, 'unreached', 2, 1],
},
})
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">Pray for them →</a>`
: ''
const NS = mapProvider === 'maplibre' ? window.maplibregl : window.mapboxgl
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(cat.label.split(' — ')[0])}</span>
<div class="meta">${esc(p.country)} · pop ${Number(p.population).toLocaleString()}</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 () => {
const dataPromise = buildFeatures().catch((err) => {
mapError.value = String(err?.message || err)
return null
})
try { await initializeMap() } catch (err) {
mapError.value = String(err?.message || err); return
}
if (!map.value) { mapError.value = 'Map failed to initialize — check token and container'; return }
map.value.once('style.load', async () => {
try {
const fc = await dataPromise
if (!fc) return
try { map.value.setProjection('mercator') } catch (_) {}
addLayer(fc)
mapStore?.registerMap?.('all-people-groups', map.value)
appReady.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">Loading every people group…</div>
<div v-if="mapError" class="apg-error">The map could not be loaded. {{ mapError }}</div>
<div ref="mapContainer" class="apg-canvas" />
<div v-if="appReady" class="apg-legend">
<div class="apg-legend-title">All People Groups</div>
<div class="apg-legend-sub">{{ totalShown.toLocaleString() }} group–country locations</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>
{{ c.label }}
<span class="apg-count">{{ (counts[c.id] || 0).toLocaleString() }}</span>
</div>
<div class="apg-hint">Click a row to see all of them · click again for everything</div>
</div>
</div>
</template>