UPG Neighbor: Campaigns shows its languages on the map before one is picked

One bubble per bridge language, on its largest unreached group and sized by the unreached population a campaign in it reaches; it follows the search and the believers filter, names itself on hover and picks the language on a click. The map was empty until a language was chosen.
This commit is contained in:
Doxa Maps 2026-09-29 00:24:21 +00:00
parent cbe9158d60
commit 667eb5b91c

View file

@ -81,7 +81,14 @@ function exportAll() {
}
// ─── Map: believers (green) and the unreached they would reach (red) ─────────
const SRC_R = 'cp-r', SRC_U = 'cp-u'
// Before a language is picked, the map shows the list itself: one bubble per bridge language, placed on
// its largest unreached group and sized by the unreached population it reaches. A click picks it.
const SRC_R = 'cp-r', SRC_U = 'cp-u', SRC_B = 'cp-b'
const anchor = (b) => b.unreached.reduce((m, g) => (!m || g.pop > m.pop ? g : m), null)
function bridgeFeatures() {
if (active.value) return fc([])
return fc(list.value.map((b) => { const g = anchor(b); return g && { type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] }, properties: { lang: b.lang, pop: b.unreachedPop } } }).filter(Boolean))
}
const fc = (features) => ({ type: 'FeatureCollection', features })
// Hover label: after a language is picked, its believers (green) and the unreached they reach (red)
// were anonymous until clicked, and a green dot could not be clicked at all. Same label as Mobilize.
@ -96,8 +103,12 @@ function repaint() {
// be dropped for good: the start view showed no dots at all (rega, upgn6). Try again once idle.
if (!m.isStyleLoaded?.()) { if (!repaintPending) { repaintPending = true; m.once('idle', () => { repaintPending = false; repaint() }) } return }
const b = active.value
setSrc(m, SRC_B, bridgeFeatures())
setSrc(m, SRC_U, fc(b ? b.unreached.map(pt) : []))
setSrc(m, SRC_R, fc(b ? b.reached.map(pt) : []))
if (!m.getLayer('cp-b')) m.addLayer({ id: 'cp-b', type: 'circle', source: SRC_B, paint: {
'circle-color': UNREACHED, 'circle-opacity': 0.45, 'circle-stroke-width': 1.5, 'circle-stroke-color': E_COLORS[1],
'circle-radius': ['interpolate', ['linear'], ['ln', ['max', ['get', 'pop'], 1]], 8, 3, 12, 6, 16, 12, 19, 18] } })
if (!m.getLayer('cp-u')) m.addLayer({ id: 'cp-u', type: 'circle', source: SRC_U, paint: {
'circle-color': UNREACHED, 'circle-opacity': 0.7, 'circle-stroke-width': 0.6, 'circle-stroke-color': '#fff',
'circle-radius': ['interpolate', ['linear'], ['ln', ['max', ['get', 'pop'], 1]], 6, 2, 12, 4, 16, 8, 19, 12] } })
@ -121,6 +132,7 @@ function frame() {
} catch (_) { /* a camera call on a map mid-teardown is not worth an error */ }
}
watch(() => [nbState.lang, data.value], () => { repaint(); frame() })
watch(list, () => repaint()) // the bubbles follow the search and the believers filter
onMounted(async () => {
loadNearData(profileConfig).then((d) => { data.value = d; repaint(); frame() })
@ -143,10 +155,19 @@ onMounted(async () => {
const r = active.value?.reached.find((g) => g.pgid === id)
if (r) openGroup(r)
})
for (const id of ['cp-u', 'cp-r']) {
m.on('click', 'cp-b', (ev) => {
const b = data.value?.bridges.find((x) => x.lang === ev.features?.[0]?.properties?.lang)
if (b) pick(b)
})
for (const id of ['cp-u', 'cp-r', 'cp-b']) {
m.on('mouseenter', id, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = '' })
}
m.on('mousemove', 'cp-b', (ev) => {
const b = data.value?.bridges.find((x) => x.lang === ev.features?.[0]?.properties?.lang)
hover.value = b ? { x: ev.point.x, y: ev.point.y, text: `${b.langName} · ${nt('neighbor.camp.unreachedPeople', { n: fmtNum(b.unreachedPop) })}` } : null
})
m.on('mouseleave', 'cp-b', () => { hover.value = null })
const byId = (id) => active.value && [...active.value.reached, ...active.value.unreached].find((g) => g.pgid === id)
for (const id of ['cp-u', 'cp-r']) {
m.on('mousemove', id, (ev) => {