doxa-maps/plugin-designs/country-posters/country-posters-mfe.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

245 lines
12 KiB
Vue

<script setup>
/**
* country-posters-mfe.vue — Country Posters as a TWO-TAB element.
*
* @element country-posters-mfe
* @description Country Posters as ONE two-tab element — the countries map and the printable
* poster wall behind a single view switcher, structured like the UPG-100 list. The switcher is
* the library's shared ViewSelect, not a third hand-rolled copy.
*
* Driver: "Country Posters is supposed to have two tabs, structured like the
* UPG-100 list."
*
* ★ THE CONTROL IS SHARED, NOT COPIED. That is the whole point of the card: this
* is the THIRD place the SELECT VIEW switcher is needed, and it uses the library's
* ViewSelect.vue — the same component upg-100-mfe and regions-mfe render. A third
* hand-rolled copy is exactly what library/components/ViewSelect.vue was extracted
* to prevent (its own header records that a fourth divergent copy is not what the
* library is for).
*
* SAME SHAPE AS upg-100-mfe, deliberately — index.js already says this bundle is
* "the same shape as my-upg-100-list", and copying its structure is what makes the
* two elements behave identically for a reader who moves between them:
* • the pane-wrapper layout (buildPaneColumn + PANE_GRID_STYLES)
* • each pane keeps its own ProfileLoader/Pinia
* • one ShareButton in the header, per the recorded share policy
* (check:share-policy) — the MAP pane must not grow a second one
*
* LIST FIRST, MAP SECOND. The Driver's principle, already applied in upg-100-mfe:
* "maps don't have to be primary. You can use lists and grids for the primary view
* and maps as a secondary view… then they transition into the map." The poster wall
* is the concrete artefact a newcomer can act on; the map is where it is placed.
*/
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import ViewSelect from '@map/components/ViewSelect.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { useI18n } from 'vue-i18n'
import enTabs from '@map/i18n/locales/en/tabs.json'
// The pane labels are the chrome a reader sees FIRST, and they were English literals while the
// panes inside translated (the upg-100-mfe finding). GUARDED: useI18n() throws on a host that never
// installed vue-i18n, so the English catalog is the fallback, never a blank or a dotted key.
let t = (k) => enTabs[String(k).replace(/^tabs\./, '')] || String(k)
try { const i18n = useI18n(); t = (k) => { const v = i18n.t(k); return v === k ? (enTabs[String(k).replace(/^tabs\./, '')] || k) : v } } catch (_) { /* no i18n on this host */ }
const cfg = inject('profileConfig', ref(null))
const uiStore = inject('uiStore', null)
const isDark = computed(() => !!uiStore?.isDark)
const TAG = 'country-posters-map'
// One instanceId shared by BOTH panes, so a scoped cross-pane event addresses THIS
// embed even when two Country Posters elements sit on the same page.
const instanceId = computed(() => cfg.value?.instanceId || 'country-posters-mfe')
// The map pane needs the Mapbox token the host page was given; the wall does not.
const tk = computed(() => cfg.value?.tk || '')
const spec = computed(() => ({
id: 'country-posters-mfe',
tag: TAG,
// pinHint:false reaches the PANES from here, not from their own meta. A child's
// meta configures only its own LEAF page; nested in this composite each pane is
// handed the config built below, and PinHint.vue reads pinHint off the INJECTED
// profileConfig — so without this the coach-mark reappears over the cloned map.
config: { profile: 'country-poster-wall', instanceId: instanceId.value, pinHint: false },
// ORDER LIVES IN at[0], NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by at[0]
// while ViewSelect renders this array VERBATIM, so the two must agree or the tab
// strip desynchronises from the scroll column.
panes: [
{ id: 'wall', label: t('tabs.countryPosters'), at: [0, 0], config: { profile: 'country-poster-wall' } },
{ id: 'map', label: t('tabs.map'), at: [1, 0], config: { profile: 'countries-map' } },
],
// The switcher lives in this element's own header, so the layout's "… below ↓"
// pill would be a second, redundant control (same call upg-100-mfe makes).
paneHint: false,
}))
const stage = ref(null)
// DERIVED from the pane order, never a hardcoded id — the same trap upg-100-mfe
// records: `ref('map')` silently became wrong the moment the panes were reordered.
const firstPaneId = computed(() => {
const col = spec.value.panes
.filter((p) => (p.at?.[1] ?? 0) === 0)
.sort((a, b) => (a.at?.[0] ?? 0) - (b.at?.[0] ?? 0))
return col[0]?.id || 'wall'
})
const viewRaw = ref(null)
const view = computed({
get: () => viewRaw.value || firstPaneId.value,
set: (v) => { viewRaw.value = v },
})
let column = null
let offScroll = null
// PAGE-LEVEL config keys a pane INHERITS from the host page. A cap-4 composite
// nests children as custom elements in THIS document, so anything the generator
// bakes once per page (where assets are served from, which backend, the theme) has
// to be forwarded explicitly or the child falls back to its own default — the
// BUG-UPG100MFE-ASSET-PATH-DEPTH-404S failure, where every preview 404'd because
// assetsBase never reached the consumer.
// Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
// `profile`/`instanceId` cannot be overwritten (pane.config is spread last).
// ★ 'provider' MUST travel. A composite that forwards the token but not the provider hands a
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
// before it: the inner map cannot honour a key the shell never passed down.
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)
const host = cfg.value || {}
const inherited = {}
for (const k of INHERITED_PAGE_KEYS) {
if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k]
}
// `lang` is the alias the page may carry; children read `locale`.
if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang
const pcfg = { ...inherited, ...spec.value.config, ...pane.config }
if (tk.value) pcfg.tk = tk.value
el.setAttribute('profile-config', JSON.stringify(pcfg))
return el
}
function go(id) {
view.value = id
column?.scrollTo(id)
}
// ── tab 1 -> tab 2 (shell seam) ─────────────────────────────────────────────
// country-poster-wall already dispatches this scoped window event when a card's
// "Show on map" is clicked — country-poster-wall.vue:78 sets event-name. Until now
// ONLY the app shell listened (app-wrapper-material/index.html), so the action did
// nothing in a dropped embed or a nested composite: the event fired into a document
// with no handler. Owning it here is what makes the two tabs one element rather
// than two pages that happen to share a bundle.
// Pane elements by id, so the map pane can be addressed after mount.
const paneEls = {}
const SHOW_ON_MAP = 'doxa-country-posters:show-on-map'
function onShowOnMap(e) {
const d = e?.detail || {}
if (d.instanceId && d.instanceId !== instanceId.value) return
go('map')
// ★ THE FORWARDING THAT WAS NEVER WRITTEN. This handler used to end at go('map'), so the tab
// changed and the map was never told anything at all -- rega measured exactly that: "the camera
// stays at world view, nothing selected". It is not a timing bug and no amount of waiting fixes
// it; the link simply did not exist.
//
// Same shape as upg-100's: RECORD the request as an attribute, then announce it by event.
// An attribute is state, so it survives custom-element upgrade and is still readable when the
// map pane finally gets there (and when its style finishes loading later still); the event is
// only the live path for a map that is already up. Both end in one applySelectCountry() inside
// the pane, so there is one apply path with two triggers.
//
// The attribute carries the country ID, not its geometry: countries-map resolves the id against
// the same library/data/country-posters.json the wall renders from, so the bbox has exactly one
// source of truth rather than being copied through an attribute.
const countryId = d.listId ?? null
const el = paneEls.map
if (!el) return
if (countryId) el.setAttribute('select-country', String(countryId))
else el.removeAttribute('select-country')
el.dispatchEvent(new CustomEvent('doxa:select-country', { detail: { countryId } }))
}
onMounted(() => {
if (!stage.value) return
column = buildPaneColumn(spec.value, { host: stage.value })
if (!column) return
for (const pane of column.panes) {
if (!pane.config) continue
// ★ KEEP THE ELEMENT. It used to be created and dropped on the same line, so there was no
// handle to address the map pane with later -- which is part of why "Show on map" could only
// ever switch tabs here. upg-100-mfe already retained its pane elements; this matches it.
const el = paneElement(pane)
paneEls[pane.id] = el
column.mountEl(pane.id)?.appendChild(el)
}
// Tabs are a readout as well as a control — follow the user's scroll.
offScroll = column.onScroll(() => {
const cur = column.current()
if (cur && cur !== view.value) view.value = cur
})
window.addEventListener(SHOW_ON_MAP, onShowOnMap)
})
onBeforeUnmount(() => {
window.removeEventListener(SHOW_ON_MAP, onShowOnMap)
offScroll?.()
column?.destroy()
column = null
})
// Share the WHOLE MFE: this composite profile's own leaf page, TREE-RELATIVE
// (<bundle>/<profile>/index.html) — ShareButton joins it to the served tree root.
// ★ Sharing the COMPOSITE, not a child, is the BUG-SHARE-FROM-CAP4-COMPOSITE bug
// this pattern already paid for once.
const SHARE_EMBED_PATH = 'country-posters/country-posters-mfe/index.html'
// Child components' <style> blocks are dropped by defineCustomElement, so ALL CSS
// goes through useShadowStyles: the layout's grid styles + this element's chrome.
useShadowStyles(PANE_GRID_STYLES + `
/* SELF-SIZE (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT). Every layer inside this element is
position:absolute / height:100%, so the whole chain resolves to ZERO unless the HOST is
sized. A standalone leaf page papers over it with a page-level rule; inside a tab layout
no such rule exists and every pane collapses to 0 — the "elements are not embedding"
report. Declaring :host makes the element fill its slot in every deploy method. */
:host{ display:block; width:100%; height:100%; min-height:420px; }
/* ── palette: DX-life material light. NO AUTO-DARK (Driver 2026-09-20): a system set to
dark mode must not flip this element. ── */
.cpmfe{
--ui-bg:#ffffff; --ui-bg-elevated:#f3f3f1; --ui-bg-accented:#f3f3f1;
--ui-border:#d1d4cd; --ui-text:#3b463d; --ui-text-muted:#6d766e; --ui-primary:#92b195;
--header:56px;
position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;
background:var(--ui-bg); color:var(--ui-text);
font:14px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;
}
.cpmfe .hdr{
flex:0 0 var(--header); height:var(--header); position:relative;
display:flex; align-items:center; justify-content:flex-end; padding:0 12px;
background:var(--ui-bg); border-bottom:1px solid var(--ui-border);
}
/* Only the PLACEMENT lives here — the control's metrics are the shared component's. */
.viewsel{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
.cpmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }
`, 'country-posters-mfe')
</script>
<template>
<div class="cpmfe">
<header class="hdr">
<ViewSelect
:views="spec.panes"
:current="view"
legend="Select View"
label="Views"
@select="go"
/>
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
</header>
<div class="stage" ref="stage"></div>
</div>
</template>