goal (d). Both composites reflect the reader's direction onto their custom-element host with
reflectDir(), and reflectDir read `getCurrentInstance()` at the moment it ran. That call answers only
while setup or a lifecycle hook is on the stack. reflectDir is called from BOTH:
onMounted(reflectDir) -> Vue has set the current instance; this worked
watch(textDir, reflectDir) -> nothing has; the read returned null and nothing was reflected
So a host that declared lang=ar BEFORE mount got dir=rtl correctly, and a host that SWITCHED to ar
afterwards got nothing at all. Arabic then rendered in a left-to-right layout — readable glyphs in the
wrong places, which the comment above this code already names as reading like "a styling quirk rather
than the bug it is".
THAT SPLIT IS WHY IT SURVIVED REVIEW, and it is the part worth remembering: the function is not wrong
and neither caller is wrong; the pairing is. Either line read alone is correct code, and first paint —
the thing anyone actually checks — was right. Only the later switch was broken.
The instance is now captured in setup as _selfInstance, which is the fix research-map.vue already
carries for the same failure in emitFilterState (measured there under Vue 3.5.42).
TWO THINGS DELIBERATELY NOT CHANGED, so nobody has to wonder:
- reflectDir still only ever SETS dir and never clears it. That is documented directly above it — a
host page that declares its own dir owns that decision — so switching ar -> en leaving the attribute
is intended, not a second bug.
- research-map-plus-grid.vue's onMounted(() => { getCurrentInstance()... }) at :422 is CORRECT and is
left alone. Vue sets the current instance for the duration of a lifecycle hook, so that read
resolves. Changing it would be churn, and flagging it would be a gate crying wolf on working code.
424 lines
30 KiB
Vue
424 lines
30 KiB
Vue
<script setup>
|
|
/**
|
|
* dashboard-plus-grid.vue — Capability-4 composite: research-dashboard + people-groups-grid
|
|
* BAKED INTO ONE self-contained element, stacked to scroll.
|
|
*
|
|
* @element dashboard-plus-grid
|
|
* @description Both children are IMPORTED DIRECTLY (compiled into this ONE bundle — no runtime bundle
|
|
* injection, unlike stack-demo which pulls them via `src`). They render STACKED top-to-bottom via the
|
|
* shared StackLayout, so the host page scrolls through the rich Research Dashboard (facets + map +
|
|
* charts + table) and then the People Groups grid (live pray.doxa.life) as one continuous element.
|
|
* This is the "two elements baked together into one" element — embed <dashboard-plus-grid> anywhere.
|
|
*
|
|
* Same "import the REAL components, no fork" discipline as big-picture: the grid is the exact
|
|
* people-groups-grid component; the dashboard is the exact research-dashboard component.
|
|
*/
|
|
import { reactive, provide, inject, ref, computed, watchEffect, watch, onMounted, getCurrentInstance } from 'vue'
|
|
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
|
import { resolveEmbedBase } from '@map/utils/embedBase.js'
|
|
import { useSharedMapToken, publishMapToken } from '@map/composables/useSharedMapToken.js'
|
|
import StackLayout from '@map/layouts/StackLayout.vue'
|
|
import ResearchDashboard from '../research-dashboard/research-dashboard.vue'
|
|
import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
|
|
|
|
// ★ 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 DASHBOARD → GRID LINK ────────────────────────────────────────────────────────────
|
|
// THE BUG THIS FIXES (Driver-reported; measured 2026-09-22). Selecting anything in the
|
|
// dashboard above did not filter the grid below. Measured: the dashboard reacted every time
|
|
// (rows 22→21 on search, 21→6 on the "Ethnoreligion" facet, 6→12 on a row select) while the
|
|
// grid stayed at a byte-identical 24 cards. Row COUNT alone would have been an ambiguous
|
|
// signal here — 24 is the grid's page size, so a correct re-filter can also read 24 — which is
|
|
// why the diagnosis rests on the row IDENTITY fingerprint being unchanged too.
|
|
//
|
|
// NEITHER HALF WAS BROKEN; THE LINK WAS ABSENT. The grid's receiving end already works
|
|
// (people-groups-grid.vue declares the filter props in defineProps and watches them all), and the dashboard
|
|
// now emits (`dashboard-filter`). This handler is the translation layer between them, because
|
|
// the two speak different vocabularies: the dashboard's bus keys are snake_case facet names
|
|
// from dashboard.config.js, the grid's props are camelCase.
|
|
//
|
|
// NOT EVERY DASHBOARD FACET MAPS. The config defines religion, religion_family, wagf_region,
|
|
// wagf_block, country_code, engagement_status, language_family, primary_language, affinity_bloc,
|
|
// cluster and people_group (plus rop1_name, a search-only projection that is not an axis at all)
|
|
// — but the grid accepts only its declared props. The unmapped ones are DROPPED rather
|
|
// than guessed at: pushing a value into a prop the grid filters differently would silently
|
|
// show the WRONG rows, which is worse than the honest current behaviour of not narrowing on
|
|
// that axis. Widening this map means adding real props to the grid first.
|
|
//
|
|
// ★ THE WORKED EXAMPLE, so the next author does not "helpfully" add the obvious-looking line.
|
|
// `religion_family` is NOT `religion`, even though the names invite it. Measured: clicking the
|
|
// facet "Hinduism 693" sets filterSet.religion_family = "Hinduism" (a family NAME, derived in
|
|
// dashboard.config.js from the FIRST LETTER of the religion code). The grid's `religion` prop
|
|
// matches the religion CODE/value (people-groups-grid.vue: propFilters → valueMatches). Mapping family→religion
|
|
// would compare "Hinduism" against codes and filter the grid to ZERO rows — a broken screen
|
|
// that looks like a data bug. A real religion_family filter needs a matching prop on the grid.
|
|
//
|
|
// ★ COUNTRY WAS THE FIFTH TO EARN ITS MAPPING (Driver-reported 2026-09-22: "when I click on
|
|
// India in the top countries nothing happens and the people groups grid just stays the same").
|
|
// That report was correct, and the cause was this map: `country_code` was one of the eight
|
|
// dropped facets, so the "Top countries" chart had no prop to reach and the grid could not
|
|
// react. The fix was NOT to guess a mapping — it was to add a real `countryCode` prop to the
|
|
// grid first, then map onto it, which is the procedure the paragraph above prescribes.
|
|
//
|
|
// It passes the same test religion_family FAILS. The test is "does the facet filter over the
|
|
// SAME field as the prop?":
|
|
// religion_family → DERIVED (first letter of the religion code → "Hinduism"); no such field
|
|
// exists on a row. Still dropped.
|
|
// country_code → dashboard.config.js:80 is `get: (r) => lbl(r.country_code)`, the label of
|
|
// the very field the grid's filter reads. Same field, same rows.
|
|
// The label-vs-value gap that remains ("India" here, "IND" in the row) is absorbed by the
|
|
// grid's shared valueMatches() normaliser, which compares against value AND label. Verified on
|
|
// the live 2,107 rows: 175 countries, 100% populated, and zero normalisation collisions between
|
|
// any two of them. Seven facets remain deliberately unmapped, on the same rule.
|
|
// ★ THE TREE'S OWN AXES EARN THEIR MAPPINGS (card BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-
|
|
// WITH-GRID). The Driver: "the people place language, religion sidebar… is not working with
|
|
// interoperability between itself and with the people groups grid beneath it." Part of that was
|
|
// an affordance bug in the rail (fixed there), but part was real: the rail's People and Language
|
|
// trees filter on facet keys that had NO entry here, so a perfectly correct click could not
|
|
// narrow the grid. Each addition below was measured against the SAME field test, on the live
|
|
// 2,107 rows (2026-09-22) — never guessed.
|
|
//
|
|
// affinity_bloc -> rop1 SAFE, and it needs NO new prop. dashboard.config.js reads it
|
|
// as code(r.rop1) — the CODE ('A008') of the very field the
|
|
// grid's existing rop1 filter indexes (u.rop1). valueMatches()
|
|
// compares the selection against value AND label, so either
|
|
// spelling selects the same rows. Measured: 16 distinct blocs,
|
|
// 0 normalisation collisions on either side. This is the
|
|
// country_code case exactly — same field, only the spelling
|
|
// differs. (It once had a sibling facet `rop1` holding the
|
|
// LABEL of the same object; that duplicate axis is gone — see
|
|
// the FACET_TO_PROP note below.)
|
|
// engagement_status -> engagement SAFE. `get: (r) => lbl(r.engagement_status)` is the label of
|
|
// u.engagement_status, already in the grid's fetch, a
|
|
// {value,label} pair on 100% of rows. 2 distinct values, 0
|
|
// collisions. Needs a new prop (added on the grid first).
|
|
// primary_language -> primaryLang SAFE WITH ONE MEASURED CAVEAT. `get: (r) =>
|
|
// lbl(r.primary_language)` is the label of u.primary_language —
|
|
// same field, nothing derived, 100% populated, 863 distinct.
|
|
// The field was NOT in the grid's fetch list, so it was added
|
|
// there (a missing field would have matched u.primary_language
|
|
// === undefined and filtered to ZERO — the religion_family
|
|
// failure arriving by a different route).
|
|
// ★ THE CAVEAT, recorded rather than hidden: norm() strips case
|
|
// and [\s_&-], and across all 863 values+labels there is
|
|
// exactly ONE cross-collision — ISO code 'yom' (Yombe) vs the
|
|
// LABEL 'Yom' (code 'pil'). Selecting the language "Yom" also
|
|
// admits Yombe rows. One over-match in 863 is a far better
|
|
// outcome than an axis that cannot filter at all, but it is a
|
|
// real (small) imprecision, not a clean pass like country's
|
|
// measured zero.
|
|
//
|
|
// STILL DELIBERATELY UNMAPPED, on the same rule that keeps religion_family out:
|
|
// language_family — DERIVED. dashboard.config.js falls back to a langFamilyByIso lookup over
|
|
// the ISO code, so for 96% of rows the value exists on NO field of the row.
|
|
// There is nothing for a prop to compare against. Needs a real grid-side
|
|
// derivation before it could ever be mapped.
|
|
// religion_family — DERIVED (first letter of the religion code -> "Hinduism"). The original
|
|
// worked example below; unchanged.
|
|
// cluster / people_group — the fields (imb_reg_of_people_2 / _25) are real and collision-free,
|
|
// but they are NOT in the grid's fetch and each would need its own prop plus a
|
|
// widened field list. Left for a follow-up rather than bundled in here: they
|
|
// are the two DEEPEST tiers of the People tree, so they only become reachable
|
|
// after a user has already drilled twice, and the shallow tiers above them now
|
|
// work. Mapping them is mechanical, not risky — it is simply not yet measured.
|
|
const FACET_TO_PROP = {
|
|
wagf_region: 'wagfRegion',
|
|
wagf_block: 'wagfBlock',
|
|
// THE People axis. There used to be a second entry here — facet key `rop1` — also pointing at
|
|
// this prop, because the dashboard registered the People axis TWICE (a code facet for the tree,
|
|
// a label facet for the dropdown). Collapsing both onto one prop is what made the GRID behave
|
|
// correctly while the dashboard's own header still showed two rival controls, which is how the
|
|
// defect survived: a downstream adapter masked half of it. The duplicate facet is now gone at
|
|
// the source (dashboard.config.js), so ONE entry is both sufficient and honest.
|
|
// Value is the bloc CODE ('A012'); the grid's valueMatches() compares against value AND label,
|
|
// so it selects the same rows either spelling arrives in.
|
|
affinity_bloc: 'rop1',
|
|
religion: 'religion',
|
|
country_code: 'countryCode',
|
|
primary_language: 'primaryLanguage',
|
|
engagement_status: 'engagementStatus',
|
|
// The two DEEPEST tree tiers. Their row fields are imb_reg_of_people_2 / _25, which the grid did
|
|
// not fetch — so these changed the dashboard correctly and could never move the grid. Verified
|
|
// live before mapping (2,107 rows): both present on 2106, both {value,label}, 194 and 1736
|
|
// distinct values, ZERO collisions after normalisation and ZERO label-vs-value cross-collisions.
|
|
// Unlike language_family / religion_family, which are DERIVED and stay unmapped, these are real
|
|
// fields that only needed asking for.
|
|
cluster: 'cluster',
|
|
people_group: 'peopleGroup',
|
|
// THE PHASES OF ENGAGEMENT (row field imb_gsec). Mapped from the day it was added, because
|
|
// shipping a facet that narrows the dashboard and cannot move the grid is precisely the
|
|
// dead-button class marked `dashboardOnly` elsewhere in this file — and this one has no reason
|
|
// to join it: imb_gsec is a real row field with 5 {value,label} pairs and no value-vs-label
|
|
// cross-collision, so valueMatches() selects the same rows whichever vocabulary arrives.
|
|
gospel_access: 'gospelAccess',
|
|
// ── THE TWO FORMERLY-DERIVED-ONLY AXES, wired at last (2026-09-23) ──
|
|
// These were the "STILL DELIBERATELY UNMAPPED" pair above: their values are DERIVED
|
|
// ("Hinduism" from the religion code's first letter; "Indo-European" from the
|
|
// imb_language_family label or the ISO lookup), so no fetched row field could match them
|
|
// and mapping them would have filtered the grid to ZERO. The grid now runs the SAME
|
|
// derivations itself (people-groups-grid.vue DERIVED_AXES, importing the same @map/colors
|
|
// + @map/data modules dashboard.config.js reads), so the value the dashboard emits and the
|
|
// value the grid derives come from one owner and byte-match. With that in place, the top
|
|
// tiers of the Religion and Language trees drive the grid like every other axis.
|
|
religion_family: 'religionFamily',
|
|
language_family: 'languageFamily',
|
|
}
|
|
|
|
// Empty string (not null) is the grid's documented "no filter" value for every axis, so a
|
|
// cleared facet must RESET its prop rather than leave the previous value stuck.
|
|
const filter = reactive({ query: '', wagfRegion: '', wagfBlock: '', rop1: '', religion: '', countryCode: '',
|
|
primaryLanguage: '', engagementStatus: '', cluster: '', peopleGroup: '', gospelAccess: '',
|
|
religionFamily: '', languageFamily: '' })
|
|
|
|
function onDashboardFilter({ filterSet = {}, searchTerm = '' } = {}) {
|
|
filter.query = searchTerm || ''
|
|
for (const prop of Object.values(FACET_TO_PROP)) filter[prop] = ''
|
|
for (const [facetKey, value] of Object.entries(filterSet)) {
|
|
const prop = FACET_TO_PROP[facetKey]
|
|
if (prop) filter[prop] = value == null ? '' : String(value)
|
|
}
|
|
}
|
|
|
|
// (The one-screen DASHBOARD_H constant that used to live here is gone with the height pin — see the
|
|
// section note below. Its reasoning was sound and is preserved there; it is the map row, not the
|
|
// whole band, that now carries an explicit height, via --rd-map-h in page-scroll mode.)
|
|
|
|
// Two host-rendered sections (no `element`/`src` — the components are baked in, so we render them
|
|
// through StackLayout's per-section slots instead of the imperative element-mount path).
|
|
// ★ ASK THE DASHBOARD FOR ITS PAGE-SCROLL LAYOUT (BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID).
|
|
// research-dashboard reads inject('profileConfig') — the same channel ProfileLoader uses for the
|
|
// profile-config attribute on a standalone embed. Providing it here is how this composite opts in
|
|
// without a prop (a prop on that component would never be populated; see its own note).
|
|
//
|
|
// WHY: the dashboard is a fixed-viewport app whose table scrolls internally. Pinned to one screen
|
|
// inside a scroll page, that table became a ~269px window over 54834px of rows and captured every
|
|
// wheel event in the lower third of the band — measured, the same gesture moved the page 2311px
|
|
// lower down but only 300px over the dashboard, so the grid below was unreachable. In page-scroll
|
|
// mode the table renders at full height and the PAGE carries it, leaving nothing under the cursor
|
|
// to compete for the wheel.
|
|
// ★ AND TELL IT WHAT TO SHARE (BUG-SHARE-FROM-COMPOSITE-LINKS-TO-THE-CHILD).
|
|
// The nested dashboard's own DashboardTopBar owns the Share button, and inside this composite that
|
|
// button had no idea it was a passenger: with no shareEmbedPath it fell through ShareButton's
|
|
// ladder to tier 3, "the page's own location", which for a baked-in child resolves to the CHILD's
|
|
// standalone page. MEASURED with `share-roundtrip-smoke --browser chromium`: sharing from
|
|
// dashboard-plus-grid produced .../research-dashboard/research-dashboard/index.html, and the gate
|
|
// caught it exactly — "share URL reopened <research-dashboard-map>, not <dashboard-plus-grid>".
|
|
// A recipient got the inner dashboard ALONE, silently losing the grid half of the composite.
|
|
//
|
|
// ★ IT MUST BE TIER 1 (`shareUrl`), NOT TIER 2 — measured, after tier 2 silently did nothing.
|
|
// resolveShareUrl's ladder is `embedPath || cfg?.shareEmbedPath`, and the nested dashboard passes
|
|
// an explicit prop: <ShareButton :embed-path="SHARE_EMBED_PATH" /> in DashboardTopBar.vue:324.
|
|
// A prop always beats host config, so shareEmbedPath here was unreachable and the rebuilt bundle
|
|
// failed the gate identically. Tier 1 is the only rung that outranks a child that has already made
|
|
// up its mind — which is exactly what its docs say it is for: "a WHOLE url the HOST supplies wins".
|
|
//
|
|
// Tier 1 takes a whole url, so it is COMPUTED rather than hardcoded: resolveEmbedBase() is the same
|
|
// helper ShareButton uses (profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL → the root the
|
|
// built bundle derives from its own script url), so the link stays correct on doxa.life, on
|
|
// the live test host and in a dropped tree with no configuration. Hardcoding an absolute url here would
|
|
// pin every share to whichever host happened to build it.
|
|
//
|
|
// Note this is exactly the two-host defect class: research-dashboard standalone PASSES the same
|
|
// gate (1/1, verified this tick), and the identical component inside this composite FAILED. Only a
|
|
// gate that drives BOTH hosts can tell those apart, which is why the composite is now a target.
|
|
// ★ THE HOST'S OWN profile-config, WHICH THIS COMPOSITE USED TO THROW AWAY.
|
|
// It declared no props, so a partner's <dashboard-plus-grid profile-config='{"tk":"..."}'> lost its
|
|
// token entirely. MEASURED on a third-party origin before this: 'No Mapbox access token provided',
|
|
// 0 style and 0 tile requests to api.mapbox.com, no canvas — the JS embed, one of the two shipped
|
|
// embed methods, mounted a dashboard whose map could not draw. Our own leaf hides it, because that
|
|
// page carries a BAKED token; the failure exists only where there is no leaf, which is exactly the
|
|
// partner case. Same defect and same shape as research-map-plus-grid.
|
|
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 element down with it.
|
|
try { return JSON.parse(raw) } catch { return {} }
|
|
})
|
|
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 as well: resolveMapProvider reads profile-config.provider, and the
|
|
// config the child sees is the one provided below. Dropping it left a host that asked for maplibre
|
|
// on mapbox — demanding a token for the one configuration that exists to need none.
|
|
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.
|
|
// ★ AND SOMEBODY HAS TO ACT ON IT. This composite BAKES its children in rather than mounting them
|
|
// by tag, so there is no ProfileLoader anywhere beneath it — and ProfileLoader is the only thing in
|
|
// the tree that turns a config locale into an actual i18n locale (ProfileLoader.vue: it watches
|
|
// `config.locale || config.lang` and drives appI18n.global.locale). MEASURED with studio's
|
|
// check:host-locale-contract: this leaf answered 0 Arabic strings against a reference of 1,891 —
|
|
// not because the catalogue was missing and not because appI18n was absent (index.js provides it),
|
|
// but because nothing ever told it which language the host asked for. Its sibling composite passes
|
|
// the same gate untouched, because it mounts children by tag and each child brings its own loader.
|
|
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'
|
|
})
|
|
// ★ THE INSTANCE IS CAPTURED IN SETUP, NOT READ WHEN THIS RUNS, and that is the whole bug.
|
|
// getCurrentInstance() answers only while setup or a lifecycle hook is on the stack. reflectDir is
|
|
// called from BOTH — onMounted (where it works) and watch(textDir) (where there is no current
|
|
// instance, so the read returned null and nothing was reflected). So a host that set lang=ar before
|
|
// mount got dir=rtl correctly, and a host that SWITCHED to ar afterwards silently got nothing:
|
|
// first paint was right, which is exactly why this read as correct. Arabic then rendered in a
|
|
// left-to-right layout — the failure the comment above calls "a styling quirk rather than the bug
|
|
// it is". Measured in this tree before, under Vue 3.5.42, when research-map.vue's emitFilterState
|
|
// died the same way; the fix is its _selfInstance, and this is the same fix.
|
|
const _selfInstance = getCurrentInstance()
|
|
function reflectDir() {
|
|
try {
|
|
const host = _selfInstance?.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)
|
|
|
|
// ★ AND PROVIDE IT THE WAY ProfileLoader WOULD. This composite bakes its children in rather than
|
|
// mounting them through ProfileLoader, and ProfileLoader is the ONLY provider of 'mapboxToken' in
|
|
// the repo — so DashboardMapPanel's inject('mapboxToken', ref('')) fell through to its empty
|
|
// default here. The shared page token keeps a late-arriving token working (the leaf FETCHES one),
|
|
// which is what the composable exists for; the host's own tk still wins.
|
|
const sharedToken = useSharedMapToken()
|
|
const mapboxToken = computed(() => hostToken.value || sharedToken.value || '')
|
|
provide('mapboxToken', mapboxToken)
|
|
|
|
// The host's people-group LIST (library/lists/), threaded like the engine: into the dashboard's
|
|
// provided config below, and to the grid as the only key of its profile-config.
|
|
const hostList = computed(() => (typeof hostConfig.value?.list === 'string' ? hostConfig.value.list : ''))
|
|
const gridConfig = computed(() => (hostList.value ? JSON.stringify({ list: hostList.value }) : ''))
|
|
|
|
const SHARE_LEAF = 'dashboard-plus-grid/dashboard-plus-grid/index.html'
|
|
const shareBase = resolveEmbedBase(null)
|
|
provide('profileConfig', computed(() => ({
|
|
profile: 'research-dashboard',
|
|
layout: 'page-scroll',
|
|
shareUrl: (shareBase ? shareBase.replace(/\/+$/, '') + '/' : '') + SHARE_LEAF,
|
|
// Spread last so a host that supplies none leaves the key absent, and never overwrite the two
|
|
// settings above — those are this composite's own layout and share decisions, not the host's.
|
|
...(hostToken.value ? { tk: hostToken.value } : {}),
|
|
...(hostProvider.value ? { provider: hostProvider.value } : {}),
|
|
...(hostLang.value ? { lang: hostLang.value } : {}),
|
|
...(hostList.value ? { list: hostList.value } : {}),
|
|
})))
|
|
|
|
const appI18n = inject('appI18n', null)
|
|
watchEffect(() => { if (hostLang.value && appI18n) appI18n.global.locale.value = hostLang.value })
|
|
|
|
const sections = [
|
|
// The research-dashboard leaf SELF-TITLES (its own DashboardTopBar renders a "Research Dashboard"
|
|
// title bar), so a StackLayout section title here would be a DUPLICATE — the Driver saw "Research
|
|
// Dashboard" twice. Omit title (and sub, which StackLayout only shows alongside a title) → the leaf's
|
|
// own header is the single source of truth for this section. See BUG-RESEARCH-DASHBOARD-HEADER-STACK-WRONG.
|
|
//
|
|
// BUG-DASHBOARD-CANT-SCROLL-DOWN: the research-dashboard is a FIXED-VIEWPORT app — its shell is a CSS
|
|
// grid with `height:100%` and a `1fr` map-hero plus inner-scroll (overflow:auto/hidden) regions. When
|
|
// it renders in a scroll-page section with NO definite height, `height:100%` collapses to its 560px
|
|
// floor and its inner-scroll regions swallow the wheel, so the OUTER page never grows past it → the
|
|
// Driver couldn't scroll down to the grid. Fix: give this section a definite ONE-SCREEN height
|
|
// (minHeight === maxHeight below via the composite's `dashboardHeight`). The `1fr` map now has real
|
|
// height to fill, the dashboard is one clean scroll-page band, and the PAGE grows below it so the grid
|
|
// is reachable by page scroll. `overflow:hidden` on the section keeps the dashboard's own inner scroll
|
|
// (map/table/facets) from leaking a section scrollbar — the page owns the scroll, per the StackLayout
|
|
// contract.
|
|
// ★ NO LONGER PINNED. The min===max pin above solved a REAL defect — without a definite height
|
|
// the dashboard's height:100% collapsed to its 560px min-height and the 1fr map row had nothing
|
|
// to fill. But the pin is what forced the table into a ~269px keyhole that then owned the wheel.
|
|
// page-scroll mode solves the same original defect differently: the map row gets an explicit
|
|
// height (--rd-map-h) so it cannot collapse, and the table sizes to its rows. The section is free
|
|
// to be as tall as its content, which is what makes the page scroll past it.
|
|
{ key: 'dashboard' },
|
|
// NO section title/sub (BUILD-BIGPIC-DROP-REDUNDANT-SECTION-HEADERS). The Driver does not want a
|
|
// black element header that repeats the element's name or tells them to scroll — one header per
|
|
// element, not two. StackLayout only renders .stl-sec-head when `title` is set, so omitting it
|
|
// removes the strip entirely. The dashboard self-titles; the grid needs no label to be legible.
|
|
{ key: 'grid' }
|
|
]
|
|
</script>
|
|
|
|
<template>
|
|
<!-- No `title` and no #meta: the layout's own top bar is omitted entirely when neither is used,
|
|
so the composite shows ONLY the nested elements' own chrome. -->
|
|
<StackLayout :sections="sections">
|
|
<template #section-dashboard>
|
|
<ResearchDashboard @dashboard-filter="onDashboardFilter" />
|
|
</template>
|
|
<template #section-grid>
|
|
<!-- NO explainer line here. An earlier attempt added one as its own <p>, which the Driver
|
|
correctly rejected: it created a SECOND header and re-introduced the vertical gap between
|
|
the dashboard and the grid that we had just removed. The hint belongs INSIDE the table's
|
|
existing header bar — centred, beside the live count — so it costs zero extra height.
|
|
See DashboardDataTable's .rd-table-bar. -->
|
|
<!-- The grid is DRIVEN by the research dashboard above it: the dashboard's header owns the
|
|
search + filters, so the grid hides its own duplicates and takes external filter props.
|
|
(BUILD-BIGPIC-GRID-DRIVEN-BY-DASHBOARD-PROPS-EVENTS.) -->
|
|
<!-- show-total=false: the table above already reports the count twice (its bar and its Σ
|
|
footer), and the grid's own "Total:" line rendered 38px below the footer's
|
|
"Σ 2,107 groups" — the same number twice in adjacent rows. The grid keeps the line
|
|
when embedded ALONE, where it is the only total. -->
|
|
<PeopleGroupsGrid
|
|
:query="filter.query"
|
|
:wagf-region="filter.wagfRegion"
|
|
:wagf-block="filter.wagfBlock"
|
|
:rop1="filter.rop1"
|
|
:religion="filter.religion"
|
|
:country-code="filter.countryCode"
|
|
:primary-language="filter.primaryLanguage"
|
|
:engagement-status="filter.engagementStatus"
|
|
:cluster="filter.cluster"
|
|
:people-group="filter.peopleGroup"
|
|
:gospel-access="filter.gospelAccess"
|
|
:religion-family="filter.religionFamily"
|
|
:language-family="filter.languageFamily"
|
|
:show-search="false" :show-filters="false" :show-total="false"
|
|
:profile-config="gridConfig" />
|
|
</template>
|
|
</StackLayout>
|
|
</template>
|
|
|
|
<style>
|
|
:host { display:block; width:100%; }
|
|
</style>
|