463 lines
29 KiB
Vue
463 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) })
|
|
|
|
// ★ 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 } : {}),
|
|
// 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',
|
|
// 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>
|