doxa-maps/plugin-designs/my-upg-100-list/upg-100-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

295 lines
16 KiB
Vue

<script setup>
/**
* upg-100-mfe.vue — the my-upg-100-list NESTED-ELEMENTS MFE.
*
* @description The standalone, shareable and embeddable composite of the my-upg-100-list family — it nests the map
* and the poster wall as its own elements rather than reimplementing either.
*
* IMITATES THE SHELL, does not reinvent it. This profile is the standalone,
* shareable/embeddable version of what app-wrapper-material already does for
* 'upg-100-list': it composes the bundle's two sibling elements — the UPG-100
* map (`upg-100-list`) and the poster wall (`poster-wall`) — as PANES using the
* SAME pane-wrapper layout the shell nests (buildPaneColumn + PANE_GRID_STYLES),
* and the SAME View switcher the shell shows in its header — now the shared
* components/ViewSelect.vue, which is that control extracted from this file so
* regions-dashboard could reuse it instead of copying it a third time.
*
* Copied conventions (see layouts/app-wrapper-material/index.html, 'upg-100-list'):
* • panes: { id:'list', label:'Prayer Resources', at:[0,0], config:{ profile:'poster-wall' } }
* and { id:'map', label:'Map', at:[1,0] } — RESOURCES FIRST; see the note
* on `panes` below for why the familiar view is the on-ramp to the map.
* • per-pane element config = { ...spec.config, ...pane.config } — same tag, same
* bundle, different `profile`; one download serves both panes.
* • ONE shared instanceId for both panes (as the shell does) so the poster wall's
* scoped 'doxa-upg-100-list:show-on-map' event can address this map.
* • Motion contract is the layout's: vertical neighbours are ONE scrollable page,
* scroll-snap between them; the View tabs call column.scrollTo(id) and stay in
* sync with column.current() on scroll.
*
* Why nested ELEMENTS (not importing the sibling .vue files): each profile runs
* under its own ProfileLoader (own Pinia / stores). Mounting the bundle's own
* custom element per pane keeps that isolation intact — see ProfileLoader.vue.
*
* Share: one Share button whose embed-path is THIS profile's leaf page, so the
* whole MFE (both panes) is what gets shared/embedded.
*/
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
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'
// ★ THE SHELL'S OWN CHROME WAS ENGLISH IN EVERY LOCALE. rega measured ?lang=ar and found
// 'Select View', 'Prayer Resources' and 'Map' rendering in English while the poster wall's own
// buttons inside the panes translated correctly — because the panes each install i18n and this
// wrapper never did. The chrome a reader sees FIRST was the untranslated part.
//
// ⚠ GUARDED, like every other useI18n() call in this codebase: it THROWS on a host that has not
// installed vue-i18n, and this component is mounted by hosts we do not control. The fallback
// humanises the key so a bare host shows "Select View", never a blank or a raw dotted key.
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 cfg = inject('profileConfig', ref(null))
const tk = inject('mapboxToken', ref(''))
const uiStore = inject('uiStore', null)
const isDark = computed(() => !!uiStore?.isDark)
const TAG = 'my-upg-100-list-map'
// One instanceId shared by BOTH panes — exactly as the shell's registry does
// ('wrapper-upg-100' for map and list) so cross-pane events resolve to this app.
const instanceId = computed(() => cfg.value?.instanceId || 'upg-100-mfe')
// The PROFILES-registry-shaped spec this layout consumes (ids/labels copied verbatim).
const spec = computed(() => ({
id: 'upg-100-mfe',
tag: TAG,
config: { profile: 'upg-100-list', dataSource: cfg.value?.dataSource || 'pray-tools', instanceId: instanceId.value },
// RESOURCES FIRST, MAP SECOND (Driver 2026-09-23). "By default the first tab
// shouldn't be the map. The first tab should be the prayer resources" — that pane
// already explains what a UPG-100 list IS at the top and shows the posters, so it
// needs no explanation; a choropleth of zones does. Generalised by him as a design
// principle: "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."
//
// ORDER LIVES IN `at[0]`, NOT IN THIS ARRAY'S ORDER. buildPaneColumn SORTS by
// at[0] (pane-wrapper.js), while ViewSelect renders this array VERBATIM. Swapping
// only one of the two desynchronises the tab strip from the scroll column — the
// strip would read "Prayer Resources | Map" while scroll position 0 still held the
// map. Both are swapped here, and `view` below is initialised from the same sort.
// Labels through t(). `spec` is a computed, so switching locale re-labels the tab strip
// rather than needing a remount — which is the behaviour the locale switcher assumes.
panes: [
{ id: 'list', label: t('tabs.prayerResources'), at: [0, 0], config: { profile: 'poster-wall' } },
{ id: 'map', label: t('tabs.map'), at: [1, 0], config: { profile: 'upg-100-list' } },
],
// The View switcher lives in this element's own header, so the layout's
// "… below ↓" pill would be a second, redundant control (shell design note 2026-08-01).
paneHint: false,
}))
const stage = ref(null)
// DERIVED from the pane order, never a hardcoded id. This was `ref('map')`, which
// silently became wrong the moment the panes were reordered: the column scrolled to
// the resources pane while the strip still painted "Map" as pressed. Sorting by
// at[0] here is the SAME rule buildPaneColumn applies, so the pressed tab and the
// pane at scroll-position 0 cannot disagree.
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 || 'list'
})
const viewRaw = ref(null)
// Until the column exists, the active view IS the first pane — so the strip paints a
// pressed tab on the very first render rather than flashing with nothing selected.
const view = computed({
get: () => viewRaw.value || firstPaneId.value,
set: (v) => { viewRaw.value = v },
})
let column = null
let offScroll = null
// The mounted custom element per pane id. Kept because a cross-pane action has to reach the
// TARGET PANE'S ELEMENT: upg-100-list listens for 'doxa:select-zone' on itself rather than on
// window, precisely so two embeds on one page do not cross-talk — which means the shell has to
// know which element it mounted. Cleared on unmount with everything else.
const paneEls = {}
// PAGE-LEVEL config keys a pane INHERITS from the host page.
//
// A cap-4 composite nests its children as custom elements in THIS document, so there is no
// inner leaf page to hand them the page-scoped values an iframe child would have received
// from its own generated index.html. Anything the generator bakes ONCE PER PAGE — where
// assets are served from, which backend to call, the theme — has to be forwarded explicitly
// or the child silently falls back to its own default.
//
// BUG-UPG100MFE-ASSET-PATH-DEPTH-404S (2026-09-20): `assetsBase` was not forwarded. The
// generator emitted the CORRECT value ("../../assets", identical to every sibling leaf), but
// the poster-wall pane never saw it and fell back to its own './assets' default — which
// resolves against the LEAF directory, so all 27 previews requested
// upg-100-mfe/assets/previews/<slug>.webp and 404'd. The depth calculation was never wrong;
// the value simply never reached the consumer.
//
// Forwarded by KEY LIST, not by spreading the whole host config: a pane's own `profile` /
// `instanceId` must NOT be overwritten by the host's, which is what makes a pane a distinct
// view at all. Anything a pane declares still wins (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', '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)
}
// Cross-pane action (shell seam): the poster wall dispatches a scoped window event
// when a card's "Show on map" is clicked. The pane owner scrolls to the map pane AND
// tells the map WHICH zone to isolate.
//
// ★ SCROLLING WAS ONLY HALF THE HANDOFF, and the missing half was invisible because the
// visible part worked: go('map') moved the reader to the map, so the action looked like it
// had happened, while the map still showed all 27 zones. The Driver's brief for this is
// "when I click Show on map it should be basically the same as if I searched that result in
// the search bar" — i.e. ISOLATED, not merely scrolled to.
//
// The map half was already built and waiting: upg-100-list.vue listens for 'doxa:select-zone'
// ON ITS OWN ELEMENT (not on window, so several embeds never cross-talk) and routes it through
// onLegendSelect — the same path a legend row tap and the search bar use. So the shell owes it
// exactly one re-dispatch, onto the element it mounted, and nothing here re-implements
// selection. detail.listId carries the zone, or null to un-isolate, which is what the wall
// already sends for its green toggle.
function onShowOnMap(e) {
const d = e?.detail || {}
if (d.instanceId && d.instanceId !== instanceId.value) return
go('map')
// ★ RECORD THE REQUEST, THEN ANNOUNCE IT — in that order, and the order is the fix.
// The comment that used to sit here called the miss "the degradation we want": a pane that had
// not mounted got nothing, and the scroll still happened. rega measured what that actually costs
// — the FIRST "Show on map" did nothing at all, and only a SECOND click, after the map had
// loaded, selected the zone. A control the user must press twice reads as broken.
//
// An element dispatch has no replay, so it can only reach a listener that already exists. An
// ATTRIBUTE is state: it survives custom-element upgrade, it is readable whenever the pane
// finally gets there, and it is still readable if the pane's data arrives later still. So the
// selection is written down first and the event is kept purely as the live path for a map that
// is already up. Both triggers end in the same applySelectZone() inside the pane, so there is one
// apply path with two triggers rather than two ways to select.
const zoneId = d.listId ?? null
if (paneEls.map) {
if (zoneId) paneEls.map.setAttribute('select-zone', String(zoneId))
else paneEls.map.removeAttribute('select-zone')
paneEls.map.dispatchEvent(new CustomEvent('doxa:select-zone', { detail: { listId: zoneId } }))
}
}
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
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('doxa-upg-100-list:show-on-map', onShowOnMap)
})
onBeforeUnmount(() => {
window.removeEventListener('doxa-upg-100-list:show-on-map', onShowOnMap)
offScroll?.()
column?.destroy()
column = null
// Drop the element references with the column that owned them — holding a detached custom
// element after unmount is a leak, and a stale one would be dispatched into on remount.
for (const k of Object.keys(paneEls)) delete paneEls[k]
})
// 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.
const SHARE_EMBED_PATH = 'my-upg-100-list/upg-100-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% (.upgmfe -> .pane-col -> .pane), so the whole chain resolves to
ZERO unless the HOST itself is sized. The standalone leaf page papered over it with a page-level
rule, but inside a tab layout no such rule exists -> the header rendered and every pane collapsed
to 0, which is the "elements are not embedding" report. Declaring :host here makes the element
fill whatever slot it is mounted into, in every deploy method. */
:host{ display:block; width:100%; height:100%; min-height:420px; }
/* ── palette: DX-life material light ── */
.upgmfe{
--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;
}
/* NO AUTO-DARK. The DX design pattern is material LIGHT (Driver 2026-09-20): a system set to dark
mode must NOT flip this element. The prefers-color-scheme:dark override that used to live here is
removed deliberately — the light palette above is the only theme. */
/* ── header: same height/border as the shell; switcher centred; share at right ── */
.upgmfe .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);
}
/* ── Select View fieldset ──
The control's own metrics now live in the SHARED library component
(components/ViewSelect.vue) — this file used to carry a hand-copied second
implementation of the shell's .viewsel/.seg rules. Only the PLACEMENT stays
here, because centring in this particular header is this layout's decision,
not the control's. */
.viewsel{
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
}
/* ── stage: the pane-wrapper column mounts here (its .pane-col is absolute) ── */
.upgmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }
/* the live custom element fills its pane */
.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }
`, 'upg-100-mfe')
</script>
<template>
<div class="upgmfe">
<header class="hdr">
<ViewSelect
:views="spec.panes"
:current="view"
:legend="t('tabs.selectView')"
:label="t('tabs.views')"
@select="go"
/>
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
</header>
<div class="stage" ref="stage"></div>
</div>
</template>