doxa-maps/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue
Doxa Maps 3cd8a8cd30
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source c2af6b16
2026-09-26 07:01:59 +00:00

469 lines
29 KiB
Vue

<script setup>
/**
* research-map-plus-grid.vue — Capability-4 composite: doxa-research-map + people-groups-grid
* stacked to scroll. THE MOBILE SIBLING of dashboard-plus-grid.
*
* @element research-map-plus-grid
* @description The monolithic 7-tab research map (cap-3) on top, the People Groups grid beneath,
* stacked via the shared StackLayout so the host page scrolls through both. Each child is
* composed by TAG + BUNDLE URL (the stack-demo `element`/`src` contract), so each one boots
* through its OWN bundle entry and stays individually embeddable.
*
* ── WHY THIS EXISTS AS A SEPARATE PLUGIN (the agent 2026-09-22) ─────────────────────────────────
* The first idea was ONE cap-4 plugin that SWAPS its top element by aspect ratio — research
* dashboard on desktop, research map on mobile. The agent reversed that mid-message, verbatim:
* "We made the wrong choice of saying that we should swap elements based on mobile and desktop...
* That would be really complex. It'd be easier just to have two separate capability-4 plugins."
*
* So there are two plain composites and NEITHER knows about the other:
* - dashboard-plus-grid = DESKTOP — research-dashboard + grid
* - research-map-plus-grid = MOBILE — research map + grid (this file)
* Which one appears is chosen by aspect ratio AT THE SPA LEVEL, never inside a plugin.
*
* ★ WHY THE SPLIT IS ALSO THE BETTER ENGINEERING, not just the simpler one. A self-swapping
* element would be un-embeddable in any fixed context: deploy methods 1 (iframe) and 3
* (direct-JS) both place an element at a size the HOST controls, so an element that silently
* changes what it IS based on its own width gives an embedder no way to ask for the one they
* want. Two elements keep both independently embeddable.
*
* ★★ WHY THIS COMPOSES BY src AND DOES **NOT** BAKE ITS CHILDREN — the non-obvious part.
* dashboard-plus-grid IMPORTS its two children's .vue files directly and compiles them in. That
* works there because research-dashboard owns NO MAP (it is SVG charts). Doing the same with the
* research map would SILENTLY SHIP A BROKEN ELEMENT, because importing a child .vue BYPASSES its
* index.js — and doxa-research-map/index.js is where four load-bearing things happen:
* 1. registerStrategies(import.meta.glob('./src/colors/*.js')) — its THREE bundle-private colour
* strategies (affinity-block, doxa-region, resource). Without them the map's colour modes are
* missing.
* 2. app.provide('profileModules', import.meta.glob('./*.vue')) — the map is mounted through
* ProfileLoader, NOT as a bare component; with no registry it cannot resolve its own profile.
* 3. createPinia() + createAppI18n() + provide('appI18n') — the same appContext pair whose
* absence caused BUG-VUE-I18N-CODE27 (a thrown setup() removes the subtree with an EMPTY
* SyntaxError, which cost the desktop sibling its Share / Zoom controls before it was found).
* 4. mapboxgl.setRTLTextPlugin, installed once per page.
* The failure mode is the dangerous one: the build stays GREEN and the defect is a blank or
* half-coloured band at runtime. So: compose by tag+src, let the real bundle entry run.
* This also matches the standing rule for nesting maps (avoid the Mapbox peer + the one-shot
* customElements.define collision). Measured 2026-09-22: NO composite in this repo bakes a map
* component directly — every map is composed via its own bundle.
*
* ── THE ONE REAL DIFFERENCE FROM THE DESKTOP SIBLING ─────────────────────────────────────────
* The grid's OWN search bar and filters are ENABLED here. In dashboard-plus-grid they are turned
* OFF (show-search=false, show-filters=false) because the research dashboard's header owns those
* controls and drives the grid through props — two search bars would be a duplicate. The research
* map has NO such header, so switching them off here would leave the grid with no way to be
* searched or filtered at all. the agent, verbatim: the grid's search bar + filters must be toggled
* ON via prop. This is why the swap-inside-one-element idea was genuinely complex: the two top
* elements do not merely look different, they imply OPPOSITE control ownership below.
*/
import { computed, reactive, watch, onMounted, onBeforeUnmount, getCurrentInstance, watchEffect, ref } from 'vue'
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import StackLayout from '@map/layouts/StackLayout.vue'
import { resolveEmbedBase } from '@map/utils/embedBase.js'
import { publishMapToken } from '@map/composables/useSharedMapToken.js'
// ★ THE HOST'S INLINE STYLE MUST NOT LAND ON OUR ROOT.
// This SFC is mounted AS a custom element, so a style attribute the HOST page puts on the tag falls
// through onto this component's root element. MEASURED 2026-09-25 with a host style of
// "display:flex; position:relative; height:100%": that exact string appeared verbatim on the inner
// root div of this component — the gallery sets one on every tab, and on the Prayer Walk Planner
// the same fallthrough collapsed the plugin to 76px. The element's own :host rules are the contract
// for how it sizes itself; a host styling the TAG is styling the box, not our internals.
defineOptions({ inheritAttrs: false })
// ★ THE HOST'S OWN profile-config, WHICH THIS COMPOSITE USED TO THROW AWAY.
// It declared no props at all, so a partner's <research-map-plus-grid profile-config='{"tk":"..."}'>
// had its token dropped on the floor. On our own generated leaf that was invisible: the leaf page
// carries a BAKED token, assigns window.MAP_TOKEN, and the shared-token bridge hands it to the
// nested map. A partner page has no leaf and no baked token, so the JS embed — one of the two
// shipped embed methods — mounted a map that could not draw. MEASURED on a third-party origin
// before this: 'No Mapbox access token provided', 0 style and 0 tile requests, no canvas.
const props = defineProps({
/** The host's JSON config, exactly as the embed snippet writes it. */
profileConfig: { type: [String, Object], default: null },
})
const hostConfig = computed(() => {
const raw = props.profileConfig
if (!raw) return {}
if (typeof raw === 'object') return raw
// A malformed attribute must not take the whole element down with it — the map half is more
// useful token-less than the page is with a thrown setup().
try { return JSON.parse(raw) } catch { return {} }
})
// Publish page-wide as well as threading it below. Threading covers THIS child deterministically;
// publishing covers any element on the page that mounted before the attribute was read, which is
// the shape the shared-token composable exists for.
const hostToken = computed(() => {
const tk = hostConfig.value?.tk
return typeof tk === 'string' && tk ? tk : ''
})
if (hostToken.value) publishMapToken(hostToken.value)
// ★ THE PROVIDER IS A HOST KNOB TOO, AND FORGETTING IT BREAKS THE KEYLESS CASE ENTIRELY.
// resolveMapProvider's ladder starts at profile-config.provider, and the child reads its OWN
// profile-config — the one this file writes. A host asking for <research-map-plus-grid
// profile-config='{"provider":"maplibre"}'> therefore got a child still on mapbox, which then
// demanded a token. MEASURED on a third-party origin before this: 'No Mapbox access token
// provided', 0 tiles, no canvas — for the exact configuration whose whole purpose is needing no
// token. Keyless at volume is the reason MapLibre support exists; a composite that drops the
// prop cancels it.
const hostProvider = computed(() => {
const p = hostConfig.value?.provider
return p === 'maplibre' || p === 'mapbox' ? p : ''
})
// ★ THE LOCALE THE HOST ASKED FOR, THREADED DOWN LIKE THE TOKEN AND THE PROVIDER.
// A composite is the only thing between a partner's `profile-config` and the elements that render
// the text, and this knob was not being passed: `{"lang":"ar"}` on the composite reached nothing,
// so the page stayed English however complete the catalogues were. MEASURED alongside the tk and
// provider holes, and the same shape of defect — a knob the snippet documents, read by nobody.
// setRuntimeLocale is called here as well as threaded, because the composite's OWN chrome is read
// through tStatic, and a composite sits above every ProfileLoader on the page rather than under one.
const hostLang = computed(() => {
const l = String(hostConfig.value?.lang || hostConfig.value?.locale || '').split('-')[0].toLowerCase()
return SUPPORTED_LOCALES.includes(l) ? l : ''
})
watchEffect(() => { if (hostLang.value) setRuntimeLocale(hostLang.value) })
// The host's people-group LIST (library/lists/), threaded down like the provider: into the map's
// profile-config and as the only key of the grid's.
const hostList = computed(() => (typeof hostConfig.value?.list === 'string' ? hostConfig.value.list : ''))
// ★ RTL IS A DIRECTION, NOT A TRANSLATION, and nothing was setting it here.
// createAppI18n reflects an RTL locale onto document.documentElement — right for a leaf page it
// owns, useless for an element dropped into somebody else's page, which is the case this composite
// exists for. A host that asks for Arabic through profile-config got Arabic WORDS in a
// left-to-right layout: readable glyphs in the wrong place, which reads as a styling quirk rather
// than the bug it is.
//
// Set on the custom-element HOST rather than on the layout inside it, deliberately: `dir` takes
// effect through the inherited CSS `direction`, and inheritance crosses shadow boundaries — so one
// attribute on the host covers this composite's own chrome AND every nested element beneath it,
// which is the half the audit found missing. Only ever SET, never cleared: a host page that already
// declares its own dir owns that decision.
const textDir = computed(() => {
const l = hostLang.value || getRuntimeLocale() || detectLocale()
return RTL_LOCALES.has(l) ? 'rtl' : 'ltr'
})
function reflectDir() {
try {
const host = getCurrentInstance()?.proxy?.$el?.getRootNode?.()?.host
if (host && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
}
onMounted(reflectDir)
watch(textDir, reflectDir)
// The absolute url this composite is shared and iframed as. Built here rather than written as a
// literal because resolveEmbedBase() walks profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL →
// the root this bundle derives from its OWN script url, so the link is right on doxa.life, on
// nuxtbase.app and in a dropped tree with nothing configured.
//
// It MUST be absolute, which is the non-obvious part. resolveShareUrl returns a tier-1 shareUrl
// VERBATIM (embedBase.js:95 — `return whole.trim()`, no resolution), and ShareButton feeds the same
// string into its embed snippet: <iframe src="{shareUrl}">. A relative value would look correct in
// the share LINK, because the browser resolves it against this page, and then 404 in the EMBED,
// because it resolves against the partner's page instead. Tier 1 is documented as "a WHOLE url" for
// exactly this reason.
const SHARE_URL = (() => {
const base = (resolveEmbedBase(null) || '').replace(/\/+$/, '')
const leaf = 'research-map-plus-grid/research-map-plus-grid/index.html'
return base ? base + '/' + leaf : leaf
})()
// One-screen height for the fixed-viewport research-map band. Same reasoning as
// dashboard-plus-grid's DASHBOARD_H: the research map is a FIXED-VIEWPORT app whose shell is a CSS
// grid with height:100% and a 1fr map hero plus inner-scroll regions. Rendered in a scroll-page
// section with NO definite height, height:100% collapses to its floor and its inner-scroll regions
// swallow the wheel, so the OUTER page never grows past it and the grid below is unreachable.
// Pinning min === max gives it a definite height to fill while the page keeps growing below.
// svh (small viewport height) avoids the mobile URL-bar jump — which matters MORE here than on the
// desktop sibling, because this composite is the mobile one.
const MAP_H = '88svh'
// src is resolved by the browser relative to THIS leaf page, which is served two levels deep
// (doxa-maps/research-map-plus-grid/research-map-plus-grid/index.html) — so go UP TWO to reach a
// sibling bundle's tree (doxa-maps/<bundle>/<bundle>.js). An off-by-one here is a 404 and a
// SILENTLY EMPTY section: StackLayout's .catch() deliberately leaves a failed section's host empty
// so the rest of the stack still renders, which means a wrong path costs you a band with NO error.
// `bun run check:composite-srcs` resolves every composite src against the built tree to catch it.
// ★ A DEV ENTRY AND A BUILT BUNDLE DO NOT LIVE AT THE SAME URL, so one literal cannot name both.
// In dev, vite serves each bundle's SOURCE entry at /plugin-designs/<bundle>/index.js; there is no
// <bundle>/<bundle>.js to reach, so '../../doxa-research-map/doxa-research-map.js' 404s and the
// section renders EMPTY (StackLayout's .catch() keeps the rest of the stack alive, which is exactly
// why an empty band is easy to miss). Same helper, same reasoning, as stack-demo.vue — which hit
// this first and wrote down that a literal '../..' "looks correct in review and is invisible to a
// gate that only ever checks the built tree", which is why check:composite-srcs passes on it.
// import.meta.env.DEV is statically replaced at build time, so the production branch is the ONLY
// one that survives into the IIFE: the built output is byte-identical to the literal it replaces.
const bundleSrc = (bundle, file) =>
import.meta.env.DEV ? `/plugin-designs/${bundle}/index.js` : `../../${bundle}/${file}.js`
// ─── ONE FILTER STATE, TWO HALVES (BUILD-LINKED-FILTERING, the agent 2026-09-25) ───────────────
// The composite owns the filter; the children report theirs upward and accept ours downward. The
// four facets are the HONEST OVERLAP between what a research map filters by and what a people-group
// grid filters by — region, religion, language, engagement. Country, bloc and cluster live on only
// one side, so linking them would mean inventing a meaning for the other; they stay local.
//
// ★ EACH CHILD STAYS INDEPENDENTLY EMBEDDABLE. Both directions travel as DOM CustomEvents and
// attributes, so a child mounted ALONE hears nothing and is handed nothing, and behaves exactly as
// it does today. That is the contract this composite was built on and the reason the halves are
// separate elements at all.
// ★ BOTH FORMS ARE KEPT ON PURPOSE. The grid filters by the wagf_region VALUE ('africa'); the map
// wants that same slug (its region highlight builds an ISO match from a slug-keyed map, and its
// legend tree node ids are `region:<slug>`), while the LABEL is what a human-facing surface would
// show. Keeping value and label together means neither half has to guess, and the reverse direction
// can hand the grid a slug derived from the label the map reports.
// ★ REGION CARRIES TWO FORMS AND THE OTHER TWO CARRY ONE — because that is what the halves
// actually speak, not because uniformity was worth the extra field. Region's slug is a THIRD string
// ('africa') that neither half's data carries as a label, so both forms have to travel. Religion
// and language link on the display NAME alone, and each half resolves that name itself: the map
// reverses RELIGION_FAMILIES to get its legend key, the grid runs the same shared table forward per
// row to derive `__religion_family`. The mapping lives in library/colors/religion.js and is copied
// nowhere, least of all here — a table beside the data is the thing that goes stale.
//
// ★ ONE SELECTION AT A TIME, BECAUSE THE MAP HAS ONE. MEASURED in mapStore: selectLanguage() nulls
// selectedRegion by design (Bug 13, mutually exclusive legend selections), and religion/language/
// region all end at the same applyDimFilter, so the map can light exactly one of the three. A
// composite that let the grid hold all three would put the two halves into a disagreement the map
// cannot resolve and the reader cannot see. So the composite mirrors the map's own exclusivity: a
// new facet replaces the previous one, exactly as clicking a second legend row does.
const linked = reactive({ region: { value: '', label: '' }, religion: '', language: '' })
// ★ WHAT THE LIST IS SHOWING, so the map can go there (BUILD-GRID-SELECTION-FLIES-THE-MAP).
// The link ran one way: the map narrowed the grid, and the grid moved nothing. The grid already
// reports the slugs it is showing on every `groups-filtered`, so the honest wire is the one that
// already exists — no new control, and a grid mounted alone still emits into a room with no
// listener. This composite does not know where any people group IS; the map does, so it gets the
// slugs and frames them itself.
//
// CAPPED, and the cap is the point rather than a performance worry: a set this large is not a
// selection, it is "almost everything", and framing it would swing the camera to a world view that
// tells the reader nothing while moving a lot. Below the cap the set is specific enough that seeing
// where it sits is the whole value.
const FIT_MAX_SLUGS = 400
const gridSlugs = ref([])
function clearExcept(keep) {
if (keep !== 'region' && (linked.region.value || linked.region.label)) { linked.region.value = ''; linked.region.label = '' }
if (keep !== 'religion' && linked.religion) linked.religion = ''
if (keep !== 'language' && linked.language) linked.language = ''
}
const slugify = (label) => String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
// Guard against the echo: grid -> composite -> map -> composite -> grid. Each direction ignores an
// update that says what it already knows, so a round trip stops after one lap.
// ★ IGNORE OUR OWN ECHO. MEASURED: a map-filtered event set the region, pushToChildren wrote the
// grid's attribute, the grid re-filtered and emitted `groups-filtered` — whose activeFilters were
// still EMPTY at that instant — and the composite adopted that as "the reader cleared the filter",
// wiping the selection it had just applied. Both handlers fired, both did exactly what they were
// told, and the round trip cancelled itself in under a second. A push is not a user action, so the
// window in which we are pushing is a window in which children have nothing to tell us.
let pushing = false
function adopt(next) {
if (!next) return
// A PATCH, not a replacement: each half reports only the facets it owns a control for, so an
// absent key means "no opinion" and must not clear a selection the other half just made.
if (next.region && (next.region.value || next.region.label)) {
const value = next.region.value || ''
const label = next.region.label || ''
if (value !== linked.region.value || label !== linked.region.label) {
linked.region.value = value
linked.region.label = label
clearExcept('region')
}
return
}
if (next.religion) {
if (next.religion !== linked.religion) { linked.religion = next.religion; clearExcept('religion') }
return
}
if (next.language) {
if (next.language !== linked.language) { linked.language = next.language; clearExcept('language') }
return
}
// Nothing set anywhere in this report: the reader cleared whatever was active.
if ('region' in next || 'religion' in next || 'language' in next) clearExcept(null)
}
// The grid already emits `groups-filtered` as a bubbling, composed CustomEvent — built for exactly
// this (see its header). Read the facets we link out of its activeFilters payload.
function onGridFiltered(ev) {
if (pushing) return
// The grid's activeFilters carry { value, label } per facet — exactly the two forms the two
// halves need, which is why this reads both rather than picking one.
// The slugs travel even when the facets are unchanged: a search term narrows the list without
// touching any facet, and that is exactly a case where the reader wants the map to follow.
const slugs = Array.isArray(ev?.detail?.slugs) ? ev.detail.slugs : []
gridSlugs.value = slugs.length && slugs.length <= FIT_MAX_SLUGS ? slugs : []
const active = ev?.detail?.activeFilters || {}
const entry = active.wagf_region
// Religion and language travel as the NAME, which for this grid is the option's label — the same
// string the map reports for the same row. See the `linked` note for why the code stays home.
const nameOf = (f) => (f && (f.label || f.value)) || ''
adopt({
region: { value: (entry && (entry.value ?? entry)) || '', label: (entry && entry.label) || '' },
religion: nameOf(active.__religion_family),
language: nameOf(active.primary_language),
})
}
function onMapFiltered(ev) {
if (pushing) return
// The map reports the canonical label; the grid needs the slug it filters by.
const label = ev?.detail?.region || ''
// The map reports religion as a NAME too (it decodes its own code before emitting), so both of
// these cross unchanged — the translation happens inside the half that owns the vocabulary.
adopt({
region: { value: label ? slugify(label) : '', label },
religion: ev?.detail?.religion || '',
language: ev?.detail?.language || '',
})
}
let rootEl = null
// ★ PUSH THE STATE ONTO THE MOUNTED CHILDREN, DON'T JUST RE-COMPUTE THE SECTIONS.
// StackLayout applies `attrs` ONCE, when it creates each child element — that is its documented
// contract ("props in advance"). MEASURED: after the grid filtered to Africa the composite adopted
// the change and `sections` recomputed, and the map's profile-config attribute in the DOM still
// read externalFilters:{region:''} — because nothing re-applies attrs to an element that already
// exists. A computed that nobody re-reads is not a channel.
function pushToChildren() {
if (!rootEl?.querySelector) return
pushing = true
// Release after the children have re-rendered and emitted, not synchronously.
setTimeout(() => { pushing = false }, 1200)
const map = rootEl.querySelector('doxa-research-map')
if (map) {
let cfg = {}
try { cfg = JSON.parse(map.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} }
cfg.externalFilters = { region: linked.region.value, religion: linked.religion, language: linked.language, fitSlugs: gridSlugs.value }
map.setAttribute('profile-config', JSON.stringify(cfg))
}
const grid = rootEl.querySelector('people-groups-grid')
if (grid) {
grid.setAttribute('wagf-region', linked.region.value)
grid.setAttribute('religion-family', linked.religion)
grid.setAttribute('primary-language', linked.language)
}
}
watch(linked, pushToChildren, { deep: true })
// The slug set changes without any facet changing (a search term, a cleared filter), so it needs
// its own trigger — `linked` alone would leave the camera behind.
watch(gridSlugs, pushToChildren)
onMounted(() => {
rootEl = getCurrentInstance()?.proxy?.$el?.getRootNode?.() || null
rootEl?.addEventListener?.('groups-filtered', onGridFiltered)
rootEl?.addEventListener?.('map-filtered', onMapFiltered)
})
onBeforeUnmount(() => {
rootEl?.removeEventListener?.('groups-filtered', onGridFiltered)
rootEl?.removeEventListener?.('map-filtered', onMapFiltered)
})
const sections = computed(() => [
// No section title: the research map self-titles through its own chrome, so a StackLayout title
// here would render the name twice — the duplicate-header bug the agent rejected on the desktop
// sibling (BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG). One header per element.
{
key: 'map',
element: 'doxa-research-map',
src: bundleSrc('doxa-research-map', 'doxa-research-map'),
// ★ profile-config IS MANDATORY FOR THIS CHILD, and omitting it was a real shipped defect
// (found + fixed 2026-09-22 by READING the regenerated README screenshot, not by any gate).
// doxa-research-map does not mount a component directly — its bundle entry mounts ProfileLoader,
// which reads profile-config.profile to decide WHICH .vue in that bundle to load. With no
// profile-config the element still MOUNTS and still reports its tag, so a deep walk counting
// tags says "both children present" — but ProfileLoader renders its error state instead of the
// map: "doxa-map: missing or invalid profile-config prop". The composite shipped with a broken
// top half that every structural check passed.
//
// The shape below is copied from what the bundle's OWN generated leaf page sets (its
// BASE_CONFIG), so this composite and the standalone leaf ask for the identical thing.
// assetsBase is '../../assets' because this composite's leaf is served two levels deep, exactly
// as the research map's own leaf is — same depth, same prefix.
//
// NOTE the grid child needs none of this: people-groups-grid mounts its component directly and
// has no profile registry. "One child needs it" is not "both do", which is why the omission was
// easy to miss.
// ★ shareUrl: SHARE THIS COMPOSITE, NOT THE CHILD (BUG-SHARE-FROM-CAP4-COMPOSITE-LINKS-TO-ITS-
// NESTED-CHILD, same defect found and fixed on the desktop sibling dashboard-plus-grid).
// MEASURED here with share-roundtrip-smoke --browser chromium: this composite emitted
// .../doxa-research-map/research-map/index.html and the gate reported "share URL reopened
// <doxa-research-map>, not <research-map-plus-grid>". A recipient got the map alone and lost
// the grid half.
//
// It has to be tier 1 (`shareUrl`), not tier 2 (`shareEmbedPath`): resolveShareUrl's ladder is
// `embedPath || cfg?.shareEmbedPath`, and this child hardcodes a prop —
// research-map.vue:2149 `:embed-path="'doxa-research-map/research-map/index.html'"` — so a prop
// always outranks host config and tier 2 is unreachable from here. Tier 1 is documented as "a
// WHOLE url the HOST supplies wins", which is exactly this situation.
//
// SHARE_URL is absolute and computed at the top of this file — see the note there for why a
// relative value would pass the gate and still break the embed snippet.
attrs: {
'profile-config': JSON.stringify({
profile: 'research-map',
dataSource: 'pray-tools',
instanceId: 'rmpg-research-map',
// ★ NO FLY/CLUSTER TOOLBAR IN THE COMPOSITE (the agent, 2026-09-23).
// LegendTools renders at top:14px centred on the legend column — the search bar's exact
// slot — so on the Regions and Languages tabs (the legendTypes its predicate allows) it
// reads as having REPLACED the search bar. In a stack whose bottom half is a grid the
// reader is scanning, a fly-tour that moves the camera underneath fights the other half.
// The standalone leaf keeps both; this is a per-host decision, which is why it is config.
legendTools: false,
// Thread the host's token through to the child. Spread so a host that supplies none
// leaves the key ABSENT rather than empty: ProfileLoader's ladder is `config.tk ||
// sharedToken`, and an empty string would be falsy anyway, but an absent key keeps the
// child's own config identical to what its standalone leaf sets.
...(hostToken.value ? { tk: hostToken.value } : {}),
...(hostProvider.value ? { provider: hostProvider.value } : {}),
...(hostLang.value ? { lang: hostLang.value } : {}),
...(hostList.value ? { list: hostList.value } : {}),
// The composite's filter state, in the map's inbound shape. Absent keys are ignored there.
externalFilters: { region: linked.region.value, religion: linked.religion, language: linked.language, fitSlugs: gridSlugs.value },
assetsBase: '../../assets',
shareUrl: SHARE_URL
})
},
minHeight: MAP_H,
maxHeight: MAP_H
},
// No title/sub on the grid either (BUILD-BIGPIC-DROP-REDUNDANT-SECTION-HEADERS): StackLayout only
// renders .stl-sec-head when `title` is set, so omitting it removes the strip entirely.
//
// attrs = "props in advance" — set on the element BEFORE it mounts, per the StackLayout contract.
// These three are written out EXPLICITLY even though true is the component's own default, so the
// contrast with the desktop sibling is readable at the call site and a future default change
// cannot silently strip the mobile controls. show-total stays on because, unlike the desktop
// composite, no table above reports the count — this is the only total on the page.
{
key: 'grid',
element: 'people-groups-grid',
src: bundleSrc('people-groups-grid', 'people-groups-grid'),
attrs: {
'show-search': 'true', 'show-filters': 'true', 'show-total': 'true',
...(hostList.value ? { 'profile-config': JSON.stringify({ list: hostList.value }) } : {}),
// The grid already accepts these as INPUTS (its header documents countryCode for exactly this
// reason); driving them from the composite is the same channel, not a new one.
'wagf-region': linked.region.value,
'religion-family': linked.religion,
'primary-language': linked.language,
}
}
])
</script>
<template>
<!-- No `title` and no #meta: StackLayout omits its own top bar entirely when neither is used, so
the composite shows ONLY the nested elements' own chrome. -->
<StackLayout :sections="sections" />
</template>
<style>
:host { display:block; width:100%; }
</style>