diff --git a/plugin-designs/cities-internet/connectivity-map.vue b/plugin-designs/cities-internet/connectivity-map.vue
index 52ff68996..2d717154f 100644
--- a/plugin-designs/cities-internet/connectivity-map.vue
+++ b/plugin-designs/cities-internet/connectivity-map.vue
@@ -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',
)
@@ -238,12 +345,15 @@ useShadowStyles(