297 lines
13 KiB
Vue
297 lines
13 KiB
Vue
<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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|