diff --git a/plugin-designs/upg-neighbor/src/campaigns.vue b/plugin-designs/upg-neighbor/src/campaigns.vue index 280785341..9c6212730 100644 --- a/plugin-designs/upg-neighbor/src/campaigns.vue +++ b/plugin-designs/upg-neighbor/src/campaigns.vue @@ -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) => {