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.
245 lines
12 KiB
Vue
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>
|