UPG Neighbor: print a prayer packet of a reached group's unreached neighbours, on the shared packet renderer

From a green group's brief: the same list as the map, the CSV and the letter (this E-level, closest first, the closest 60, as the cover says), printed by library/composables/usePosterPrints.printPacket with the renderer UPG-100 uses, not a copy. The group set is upg100's pg_slugs shape. Agreed with upg100: the lightweight variant (the %-evangelical page is DOXA-wide today), a triangle of its own (all, unreached, these N; not DOXA's 2,107, which these are not a subset of), and one explicit record adapter (ISO3 country code, the language as a plain label, IMB's GSEC for the how-reached sentence, photos joined from the doxa list by slug when an API is configured).
This commit is contained in:
Doxa Maps 2026-09-29 00:33:38 +00:00
parent 0428f279c1
commit 5b6ed0d0f8

View file

@ -13,7 +13,8 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
import { useNeighborT } from './composables/useNeighborT.js'
import { tStatic } from '@map/i18n/index.js'
import { tStatic, getRuntimeLocale } from '@map/i18n/index.js'
import FUNNEL from '../../../data/people-groups/vision-funnel.json'
import { loadNearData, E_COLORS, UNREACHED, REACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv, downloadText } from './nearData.js'
import { buildLetter } from './letter.js'
@ -193,6 +194,71 @@ function onLetterKey(e) {
}
watch(() => nbState.pgid, () => { letterOpen.value = false })
// ─── The neighbours prayer packet (phase 4): the SHARED packet, not a copy ─────
// library/composables/usePosterPrints.printPacket with library/packet's renderer, the one UPG-100 prints
// with. The group set is upg100's shape (a pg_slugs selector); the members are the same list as the map,
// the CSV and the letter (this E-level, closest first), capped at PACKET_CAP, and the cover label says so.
// Decisions agreed with upg100 (2026-09-29):
// · variant 'lightweight': the %-evangelical page is DOXA-wide today and would describe DOXA, not these
// neighbours; lightweight prints map · triangle · profiles · wheel · points.
// · the triangle is told its own steps: all → unreached → these N. Its default puts DOXA's 2,107 directly
// above N, which would read as "these are a subset of DOXA's list"; they are not.
// · one explicit record adapter (below), because the renderer and describeGroup read their own field
// names and a mismatch prints blank, never fails.
const PACKET_CAP = 60
const packetBusy = ref(false)
const packetSlug = (g) => g.slug || g.pgid
// Photos: joined from the doxa people-group list by slug, when an API is configured (config-only and
// fail-closed, as upg100: no default host). No photo prints the renderer's own "no photo" tile.
async function doxaPhotos(groups) {
const base = (typeof window !== 'undefined' && window.MAP_APP_API_URL) || import.meta.env.VITE_API_BASE_URL || ''
const want = new Set(groups.map((g) => g.slug).filter(Boolean))
if (!base || !want.size) return new Map()
try {
const r = await fetch(`${base}/api/people-groups/list?fields=slug,image_url`)
if (!r.ok) return new Map()
const j = await r.json()
const list = Array.isArray(j) ? j : (j.data || j.people_groups || j.posts || [])
const url = (v) => (v && typeof v === 'object' ? v.value || v.url || '' : v || '')
return new Map(list.filter((x) => want.has(x.slug) && url(x.image_url)).map((x) => [x.slug, url(x.image_url)]))
} catch (_) { return new Map() }
}
const packetRecord = (g, photos) => ({
slug: packetSlug(g), name: g.name, population: g.pop, religion: g.religion,
country_code: g.iso3, // the ISO3: describeGroup localizes the country name from it
country_code_label: g.country, // the next rung: printed if the code cannot be resolved
primary_language_label: g.langName, // a plain string; describeGroup reads this rung first
imb_gsec: Number.isInteger(g.imb_gsec) ? g.imb_gsec : null, // 0-6: the "how reached" sentence
image_url: photos.get(g.slug) || '',
})
async function printNeighbourPacket() {
const g = selected.value
if (!g || !g.reached || packetBusy.value || !neighbors.value.length) return
packetBusy.value = true
try {
const all = neighbors.value, members = all.slice(0, PACKET_CAP).map((x) => x.g)
const level = nt(E_SHORT[eLevel.value])
const label = all.length > PACKET_CAP
? nt('neighbor.packet.labelCapped', { group: g.name, n: PACKET_CAP, total: all.length.toLocaleString('en-US'), level })
: nt('neighbor.packet.label', { group: g.name, level })
const zone = { id: `neighbours-${g.pgid}-E${eLevel.value}`, label, selector: { type: 'pg_slugs', values: members.map(packetSlug) } }
const [{ usePosterPrints }, photos] = await Promise.all([import('@map/composables/usePosterPrints.js'), doxaPhotos(members)])
const records = members.map((m) => packetRecord(m, photos))
const steps = (FUNNEL?.steps || []).filter((s) => s.id === 'all' || s.id === 'unreached')
const prints = usePosterPrints({
records: () => records,
lang: () => String(getRuntimeLocale() || 'en').split('-')[0],
label: (z) => z?.label || '',
downloadPrefix: 'doxa-upg-neighbor',
})
await prints.printPacket(zone, { funnel: steps.length ? { steps } : { steps: [] }, variant: 'lightweight' })
} catch (e) {
console.warn('[upg-neighbor] packet failed:', e?.message || e)
} finally {
packetBusy.value = false
}
}
function openBridge() { if (selected.value) { nbState.lang = selected.value.lang; nbState.view = 'campaigns' } }
const isBridge = computed(() => !!selected.value && !!data.value?.bridges.some((b) => b.lang === selected.value.lang))
@ -470,6 +536,7 @@ useShadowStyles(`
<div class="mb-actions">
<button v-if="isReachedSel && neighbors.length" ref="letterBtn" class="mb-btn" @click="openLetter">{{ $t('neighbor.letter.button') }}</button>
<button v-if="isReachedSel && neighbors.length" type="button" class="mb-btn ghost" :disabled="packetBusy" :aria-busy="packetBusy ? 'true' : 'false'" @click="printNeighbourPacket">{{ $t('neighbor.packet.button') }}</button>
<button class="mb-btn" :class="{ ghost: isReachedSel && neighbors.length }" @click="exportSegments">{{ $t('neighbor.mob.export') }}</button>
<button v-if="isBridge" class="mb-btn ghost" @click="openBridge">{{ $t('neighbor.mob.openBridge', { lang: selected.langName }) }}</button>
</div>