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.
295 lines
16 KiB
Vue
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>
|