Cities with Internet: red people-group dots that open the people-group panel

Driver: "cities with internet map should have red dots on the map to show me
where the people groups are." Every DOXA people group is now a dot over the
connectivity shading, using the pray-tools list that Prayer Progress draws. It
carries the slug the detail panel fetches by. The dots are #e11d48 with a white
ring so they read on the "under 25%" red countries too, and the legend gains a
"people group" row with the count.

Tapping a dot opens the same PeopleGroupDetail panel Prayer Progress uses, through
useSelectedPin and the shared LegendDesktop / LegendMobile wrappers: a card on
desktop, the bottom sheet on a phone. Tapping a country still opens its carriers.

Measured on a served built tree: 2,107 dots for 2,107 groups; a tapped dot opens
the panel with the group's data at 1280 and at 390, in English and in Arabic; the
Research Gallery tab shows the same 2,107.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 05:06:11 +00:00
parent 44fe6ee681
commit 299eb24a14

View file

@ -49,6 +49,14 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
// People-group dots open the SAME detail panel as Prayer Progress (doxa-simple-map): the
// shared store selection, the shared GO-pin highlight, and the shared legend wrappers
// that host PeopleGroupDetail on desktop and in the phone sheet.
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
import { useSelectedPin } from '@map/composables/useSelectedPin.js'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import LegendDesktop from '@map/components/legends/LegendDesktop.vue'
const LegendMobile = retryableAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
const profileConfig = inject('profileConfig', ref(null))
const isDark = computed(() => profileConfig.value?.isDark === true)
@ -62,6 +70,87 @@ const selected = ref(null)
const aiNoticeOpen = ref(true) // dismissible AI-research disclaimer in the detail sheet
const err = ref(null)
// ── People-group dots (Driver: "red dots on the map to show me where the people groups are")
// The DOXA list from pray-tools, the same list Prayer Progress draws. It carries the slug the
// detail panel fetches by. The shared pin table (tables/people-group-pins.json) has no slug,
// so its dots could not open that panel.
// A distinct red with a white ring: the "under 25%" fill is also red (#b42318), and a dot
// without the ring vanishes into it.
const PG_DOT = '#e11d48'
const uiStore = inject('uiStore', null)
const _lang = (() => { const v = inject('lang', null)?.value; return (v && v !== 'en') ? v : null })()
const dsm = new DataSourceManager({ locale: _lang })
const groups = shallowRef([])
const dotCount = ref(0)
const selectedPin = useSelectedPin({ getMap: () => mapRef.value, getActivePinColor: () => PG_DOT })
async function loadGroups() {
await dsm.init()
const raw = await dsm.setActiveSource('pray-tools')
groups.value = markRaw(raw || [])
}
// The properties PeopleGroupDetail reads (plus uniqueId for the shared highlight). Flat
// primitives only: Mapbox stringifies nested feature properties.
function groupFC() {
const feats = []
for (const pg of groups.value) {
const lng = parseFloat(pg.longitude ?? pg.lng), lat = parseFloat(pg.latitude ?? pg.lat)
if (!isFinite(lng) || !isFinite(lat)) continue
const n = pg._normalized || {}
feats.push({
type: 'Feature',
properties: {
uniqueId: pg.uniqueId, slug: pg.slug || '', name: pg.name,
language: pg.language, languageFamily: pg.languageFamily || n.languageFamily || '',
country: pg.country, countryIso: pg.countryIso, countryIsoLabel: pg.countryIsoLabel, countryName: pg.countryName,
religion: pg.religion, religionLabel: pg.religionLabel, religionName: pg.religionName,
population: pg.population, status: pg.status, description: pg.description || '', imageUrl: pg.imageUrl || '',
peoplePraying: Number(pg.peoplePraying ?? n.peoplePraying ?? 0),
},
geometry: { type: 'Point', coordinates: [lng, lat] },
})
}
return { type: 'FeatureCollection', features: feats }
}
function paintGroups(map) {
if (!groups.value.length || map.getSource('language-families')) return
const fc = groupFC()
dotCount.value = fc.features.length
// Named 'language-families' because the shared selected-pin highlight looks for it.
map.addSource('language-families', { type: 'geojson', data: fc, promoteId: 'uniqueId' })
map.addLayer({
id: 'ci-pg-dots', type: 'circle', source: 'language-families',
paint: {
'circle-color': PG_DOT,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 2.6, 4, 3.6, 8, 6, 12, 8.5],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 1, 6, 1.4, 12, 1.8],
'circle-opacity': 0.95,
},
})
map.on('click', 'ci-pg-dots', (e) => {
const f = e.features?.[0]
if (!f || !uiStore) return
selected.value = null // the carriers sheet and the group panel never stack
uiStore.selectPeopleGroup(f)
})
map.on('mouseenter', 'ci-pg-dots', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'ci-pg-dots', () => { map.getCanvas().style.cursor = '' })
// Verification handle, same contract as gospel-resources' __grState: the counts the layer holds.
if (typeof window !== 'undefined') {
window.__ciState = () => ({
groups: groups.value.length,
dots: dotCount.value,
hasLayer: !!map.getLayer('ci-pg-dots'),
rendered: map.queryRenderedFeatures({ layers: ['ci-pg-dots'] }).length,
selected: uiStore?.selectedPeopleGroup?.properties?.slug || null,
})
window.__ciMap = map
}
}
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
const BANDS = [
{ id: 'yes', label: 'connected 60%+', color: '#10b981' },
@ -123,8 +212,10 @@ function paint(map, adm0) {
})
// TAP, not hover. On a phone hover never fires, so a hover-only map is dead.
map.on('click', 'ci-fill', (e) => {
// A tap on a people-group dot belongs to the dot, not the country under it.
if (map.getLayer('ci-pg-dots') && map.queryRenderedFeatures(e.point, { layers: ['ci-pg-dots'] }).length) return
const iso = e.features?.[0]?.properties?.iso3
if (iso && byIso.value[iso]) selected.value = byIso.value[iso]
if (iso && byIso.value[iso]) { uiStore?.selectPeopleGroup(null); selected.value = byIso.value[iso] }
})
map.on('mouseenter', 'ci-fill', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'ci-fill', () => { map.getCanvas().style.cursor = '' })
@ -144,6 +235,8 @@ onMounted(async () => {
const gl = engineGlobal(ciProvider) || window.mapboxgl
if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return }
try {
// The dots are a second layer, not a precondition: if the list fails, the map still shades.
const groupsLoaded = loadGroups().catch((e) => { console.warn('[connectivity-map] people groups:', e?.message || e) })
const adm0 = await load()
const map = new gl.Map({
container: mapEl.value,
@ -162,12 +255,22 @@ onMounted(async () => {
// credit + link back is required, and gbOpen is mixed-licence with several
// countries carrying a real attribution requirement (India CC BY 2.5 IN, etc).
addMapAttribution(map, { boundaries: true })
map.once('load', () => paint(map, adm0))
map.once('load', async () => {
paint(map, adm0)
selectedPin.initialize()
await groupsLoaded
paintGroups(map)
})
} catch (e) { err.value = String(e.message || e) }
})
onBeforeUnmount(() => { try { mapRef.value?.remove() } catch (_) {} })
onBeforeUnmount(() => {
selectedPin.cleanup()
uiStore?.selectPeopleGroup(null)
try { mapRef.value?.remove() } catch (_) {}
})
const fmt = (n) => (Number(n) || 0).toLocaleString()
const groupOpen = computed(() => !!uiStore?.selectedPeopleGroup)
useShadowStyles(
'.gm{position:absolute;inset:0;overflow:hidden}' +
@ -218,7 +321,11 @@ useShadowStyles(
'.gm-list-h{font-weight:700;font-size:12px;margin-bottom:3px}' +
'.gm-list ul{margin:0;padding-left:17px;font-size:12px;line-height:1.5}' +
'.gm-note{margin-top:9px;font-size:11px;line-height:1.5;opacity:.75}' +
'.gm-err{position:absolute;top:50%;left:0;right:0;text-align:center;font-size:13px;opacity:.7}',
'.gm-err{position:absolute;top:50%;left:0;right:0;text-align:center;font-size:13px;opacity:.7}' +
'.gm-dot{width:10px;height:10px;border-radius:50%;flex:none;box-sizing:border-box;border:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.25)}' +
// The group panel uses Prayer Progress's two wrappers, switched at the same breakpoint.
'.gm-pg-mobile{display:none}' +
'@media(max-width:767px){.gm-pg-mobile{display:block}.gm-pg-desktop{display:none}}',
'doxa-connectivity-map',
)
</script>
@ -238,12 +345,15 @@ useShadowStyles(
<ShareButton :is-dark="isDark" :embed-path="'cities-internet/connectivity-map/index.html'" />
</MapToolbar>
<div class="gm-legend">
<div v-show="!groupOpen" class="gm-legend">
<div class="gm-title">Internet access where unengaged people groups live</div>
<div class="gm-keys">
<span v-for="b in BANDS" :key="b.id" class="gm-key">
<i class="gm-sw" :style="{ background: b.color }"></i>{{ b.label }}
</span>
<span v-if="groups.length" class="gm-key">
<i class="gm-dot" :style="{ background: PG_DOT }"></i>people group ({{ fmt(groups.length) }})
</span>
</div>
<div class="gm-caveat">
NATIONAL figures (World Bank). Per-city connectivity is not publicly available — do not read a city off a country.
@ -252,6 +362,14 @@ useShadowStyles(
<div v-if="err" class="gm-err">{{ err }}</div>
<!-- A tapped people-group dot: the same PeopleGroupDetail panel Prayer Progress opens. -->
<div v-if="groupOpen" class="gm-pg-desktop">
<LegendDesktop legend-type="prayer" popup-action="pray" :initially-collapsed="false" :is-dark="isDark" />
</div>
<div v-if="groupOpen" class="gm-pg-mobile">
<LegendMobile legend-type="prayer" popup-action="pray" :is-dark="isDark" />
</div>
<!-- Tap a country: bottom sheet on mobile, corner card on desktop. -->
<div v-if="selected" class="gm-sheet">
<div class="gm-sh-head">