doxa-maps/plugin-designs/my-upg-100-list/poster-wall.vue
Doxa Maps e3d25ff6db The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
  profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').

- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
  in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
  to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
  counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
  (71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
  The default list returns the same array, unfetched. A list whose membership cannot load shows no
  groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
  their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
  Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
  are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
  the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
  thread `list` to their children like `provider`.

Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.

Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
2026-09-26 05:16:02 +00:00

194 lines
11 KiB
Vue

<script setup>
/**
* poster-wall.vue — the UPG-100 POSTER WALL (pane 2 of the my-upg-100-list family).
*
* @description A wall of printable poster cards, one per 100-list entry, each with a "Show on map"
* action that hands off to the map profile in the same bundle.
*
* ★ MIGRATED ONTO THE SHARED WALL 2026-09-24. This file was 890 lines carrying its own copy of
* markup, CSS, the export modal and three print paths; the rendering now comes from
* @map/components/poster/PosterWall.vue, which country-posters (94 lines) and key-cities (75)
* already consume. This profile supplies ONLY what cannot be shared: its data, its taxonomy and
* its records provider.
*
* WHAT COULD NOT BE SHARED, and why each is here rather than in the component:
* REGION_OF / regionFor — the 100-list's own continental taxonomy, keyed by list id because a
* selector can be a wagf_block, a country_codes array or an explicit
* pg_slugs list and none of those implies a continent.
* fetchListRecords — knows the people-groups API, the selector evaluator AND the pg_slugs
* special case. Key Cities and Country Posters have entirely different
* data shapes, which is exactly why the shared wall takes a PROVIDER.
*
* THREE CAPABILITIES HAD TO BE ADDED TO THE SHARED WALL FIRST, over two ticks, because migrating
* without them would have REGRESSED this wall rather than consolidated it:
* cardStocks — the reader picks the prayer-card size at print time.
* posterAction="print" — open the print dialog instead of saving a file.
* hidePrayerListWhenSoon — no "coming soon" placeholder on a wall whose list DOES print.
* The last two are a Driver ruling, not a preference (2026-09-23): "three print buttons, each
* opening an in-browser print dialog, no silent file download as the default action. A raw
* 'Download' button and a 'Prayer List: coming soon' placeholder were exactly the two buttons he
* objected to."
*
* CROSS-PANE COMMS — unchanged and still the decoupling that matters: this wall NEVER imports the
* map. The shared component dispatches the window CustomEvent named below, scoped by instanceId,
* so several embeds on one page never cross-talk. The shell listens and drives the map pane.
*
* ASSETS. previews/<id>.webp (~65KB, 378 generated) SHIP and are what the wall paints. Full
* posters/<id>.webp do NOT ship by default (~42MB); export and print fall back to fetching
* posters/<id>.html and rasterising it in the browser. See
* IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF.
*/
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
import { normalizeRecords, extractRecords } from './data/normalize.js'
import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
const profileConfig = inject('profileConfig', ref(null))
const instanceId = computed(() => profileConfig.value?.instanceId || 'upg-100-wall')
const isDark = computed(() => profileConfig.value?.isDark === true)
// Assets resolve RELATIVE to the host page so the tree stays drop-portable —
// file://, a CDN subpath, Railway and Capacitor all behave identically.
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
// Config-only, fail-closed API base — SAME resolution as the sibling map profile. No default
// host: a dev harness that never sets window.MAP_APP_API_URL simply gets no PG-level data, and
// the shared wall hides the row-dependent buttons rather than failing at click time.
const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
|| import.meta.env.VITE_API_BASE_URL
|| ''
// ─── REGIONAL SECTIONS ───────────────────────────────────────────────────────
// clusters.json carries no region field, so the mapping lives here. It is keyed by
// list id rather than derived from the selector: a selector can be a wagf_block, a
// country_codes array or an explicit pg_slugs list, and none of those reliably
// implies the continent a reader expects to browse by.
//
// SE Asia + Oceania are ONE section deliberately (the island chains read as a
// single sweep), and the Americas gathers Brazil, the Andean/Caribbean list and the
// US-bearing scatter list.
const REGION_OF = {
// Africa
'west-africa': 'Africa', 'sudan': 'Africa', 'east-africa': 'Africa',
'central-africa': 'Africa', 'southern-africa-islands': 'Africa',
// Middle East. Maghreb+Egypt is geographically North AFRICA but belongs here:
// the region is read as a cultural/linguistic sweep.
'maghreb-egypt': 'Middle East',
'middle-east-core': 'Middle East',
// CENTRAL ASIA is its own section. Brief: "the 55 UPG Pakistan map is wrongly
// added to Middle East, it is Central Asia." It had been folded into the Middle
// East as "the same sweep continuing east", but Pakistan, Afghanistan,
// Uzbekistan, Tajikistan, Kyrgyzstan and Kazakhstan are Central/South Asia —
// filing them under the Middle East misnames six countries to save a heading.
'uncovered-nas-a': 'Central Asia',
// Southeast Asia + Oceania
'laos': 'Southeast Asia + Oceania', 'oceania-indonesia': 'Southeast Asia + Oceania',
'mainland-se-asia-philippines': 'Southeast Asia + Oceania',
// Americas
'brazil': 'Americas', 'andean-caribbean': 'Americas', 'uncovered-scatter': 'Americas',
// Europe
'europe': 'Europe',
// India and China are their OWN sections, not one lump called "Asia". Between
// them they are 1,122 people groups across 12 lists — more than every other
// section combined — so collapsing them hides the two largest prayer fields in
// the dataset behind a single continental label. Matched by id prefix below.
}
// ⚠ TAKES AN ID, while the shared wall's groupBy takes the whole ITEM. The template
// adapts it — `:group-by="(item) => regionFor(item.id)"` — rather than changing this
// signature, because the id is what the taxonomy is keyed by and every prefix rule
// below reads it directly. Passing regionFor straight in built and loaded cleanly and
// then threw `c.startsWith is not a function` at first render: the whole wall was blank,
// 0 cards, with nothing wrong at the source layer.
const regionFor = (id) => {
if (REGION_OF[id]) return REGION_OF[id]
if (id.startsWith('asia-india-')) return 'India'
// "China + Mongolia": the northern zone declares Mongolia as a member country
// (its land is drawn and highlighted), so the section heading names both rather
// than folding a sovereign country under a China label.
if (id.startsWith('asia-china-')) return 'China + Mongolia'
return 'Other'
}
// One flat list from the three cluster groups — the same 27 the map paints.
const items = computed(() => [
...(clusterLists || []),
...(clusterAsiaLists || []),
...(clusterUncoveredLists || []),
].filter(l => l && l.id))
// ─── THE RECORDS PROVIDER — the one thing that cannot be shared ──────────────
async function fetchListRecords(list) {
if (!API_BASE) throw new Error('no API configured')
// image_url + primary_language are what make a PRAYER CARD rather than a data
// tile: a face and the tongue the gospel has to reach them in. The deck shipped
// without either because they were never requested here — measured 95/95 groups
// DO have a portrait, so the omission cost every card its photo.
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion,'
+ 'image_url,primary_language'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
// ★ pg_slugs BYPASSES the shared evaluator — MEASURED live (2026-09-23): 12 of the 27
// lists (the entire India + China buckets) carry `{ type:'pg_slugs', values:[...] }`,
// and selector-evaluator.js documents that it "intentionally THROWS" on that type
// (upg-100-list.vue:1455-1461, "BUG-A FIX"). Calling evaluateSelector() unconditionally
// threw on every India/China card, which silently ate the click (the async function's
// catch surfaced the evaluator's own error text, and window.open() was never reached —
// found by polling the exportState HUD after a real click during verification, not by
// inspection: the button looked fine and clicked fine). Ported the SAME special-case
// upg-100-list.vue already uses: pg_slugs matches slug ∈ selector.values directly;
// every other selector type still goes through the shared evaluator so the map, the
// list print and the card deck can never disagree about membership.
const slugs = (list.selector && list.selector.type === 'pg_slugs')
? new Set(list.selector.values || [])
: evaluateSelector(list.selector, records)
return records.filter((r) => r && slugs.has(r.slug))
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
}
// The wall's own copy. v-html in the shared component, so the <strong> lands as markup —
// the emphasis is the point: praying for them BY NAME is the whole brief.
//
// ★ THROUGH t(), NOT A LITERAL. rega measured ?lang=ar and found this intro, the heading and
// the tag rendering in English while the wall's buttons beside them were translated — the
// copy a reader reads FIRST was the untranslated part. The <strong> travels inside the
// translated string so each language can emphasise its own words, which is not something a
// wrapper around an English sentence could do.
// Guarded like every other useI18n() here: it throws on a host without vue-i18n.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
try { t = useI18n().t } catch (_) { /* no i18n on this host */ }
const intro = computed(() => t('messages.upg100WallIntro'))
const heading = computed(() => t('messages.upg100WallHeading'))
const wallTag = computed(() => t('messages.upg100WallTag'))
</script>
<template>
<PosterWall
:items="items"
:group-by="(item) => regionFor(item.id)"
:instance-id="instanceId"
:is-dark="isDark"
:assets-base="assetsBase"
event-name="doxa-upg-100-list:show-on-map"
:heading="heading"
:tag="wallTag"
:intro="intro"
unit="UPG"
item-noun="list"
:records="fetchListRecords"
:card-stocks="true"
poster-action="print"
preview-action="print"
:hide-prayer-list-when-soon="true"
download-prefix="doxa-upg-100"
/>
</template>