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:
parent
44fe6ee681
commit
299eb24a14
1 changed files with 123 additions and 5 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue