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( -
+
Internet access where unengaged people groups live
{{ b.label }} + + people group ({{ fmt(groups.length) }}) +
NATIONAL figures (World Bank). Per-city connectivity is not publicly available — do not read a city off a country. @@ -252,6 +362,14 @@ useShadowStyles(
{{ err }}
+ +
+ +
+
+ +
+