doxa-maps/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue
doxa-maps-88655c70 16629f4906 Link religion and language between the map and the grid, by the one table they already share
A reader who picks a religion family on the map now sees the grid narrow to the
same peoples, and a language selection travels the same way. Region already
linked; these two did not, because the two halves do not name a religion the
same way and nothing yet proved what the honest translation was.

The map filters religion through its legend's flat tab, keyed by the FIRST
LETTER of the religion code ('B'); the grid filters through a derived axis that
runs library/colors/religion.js forward per row ('BLA' -> 'Buddhism'). One
shared table, read in opposite directions, copied by neither half — so the wire
carries the family NAME and each side resolves it against its own data.

Two measurements changed the design on the way:

  - mapStore.filters.religions, which the map's filter panel writes, is read by
    NOTHING: the mapLayers.applyFilters() it was meant to reach is called and
    never implemented anywhere in the tree. A link routed through it would have
    set state, passed a smoke test and dimmed no pin.
  - the map's legend selections are mutually exclusive by design, so it can
    light exactly one of region/religion/language. The composite mirrors that
    rather than letting the grid hold three facets the map cannot show.

Religion links at the FAMILY and not at the code, deliberately: a map cannot
distinguish 'BLA' from 'BMA' — both dim as Buddhism — so linking at the code
would promise a precision one half cannot keep.

check:linked-vocabulary proves it by measurement rather than by reading: it
compares the total reached over the composite's wire against the total the grid
gives for the same facet on its own (83 of 2,107 both ways), and runs the MUTANT
first — the legend key 'B' instead of 'Buddhism' — failing as NO-TEETH if the
wrong mapping scores the same as the right one. Measured: mutant 0, real 83.

The CLI doc's apk:debug quote was realigned in passing; package.json had gained
app:start-page and the doc still described the older chain.
2026-09-25 10:20:27 +00:00

401 lines
25 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 (Driver 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 Driver 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. Driver, 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 } from 'vue'
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 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, Driver 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: '' })
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.
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 }
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 })
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 Driver 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 (Driver, 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 } : {}),
// 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 },
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>