Regions: port DX-life's map on our map library, and clone the country page

Completes the clone. Two things were missing: the map both their pages render,
and the fact that a COUNTRY is a different page from a region.

THE MAP — plugin-designs/regions-dashboard/src/RegionCountryMap.vue.
Layers are DX-life's, expression for expression (CountryMap.vue:186-240): the
country-boundaries fill at 0.12, the 2px outline, the single-worldview filter
that stops the highlight drawing three times, the prayer / in-country colour
cases, the circle-sort-key that puts better-covered pins on top, the
zoom-interpolated radius, 0.75 opacity on neighbours, their seven colours.
The machinery is ours: theirs does `new mapboxgl.Map` in a Nuxt page, which
cannot work in a shadow root — container-by-id does not cross the boundary, the
async CSS link gives a first-initialising map a 0-height canvas, and the
wordmark duplicates per instance. @map/composables/useMapInstance.js already
solves all three, so this supplies only the layers. Points come down from the
parent instead of a second fetch, so the pins and the figures above them
provably describe the same rows.

THE COUNTRY PAGE — CountryDetail.vue is head + map note + map + the people-group
list, with NO stat cards and no goal cards. It now renders that, not the region
page wearing a country's numbers. Its map passes no color-by-prayer, so pins
read in-country vs surrounding, which is what its own note tells you to look
for. The list uses @map/components/tables/DataTable.vue rather than a third copy
of their 662-line UupgsList — this repo already ships that as people-groups-grid,
and nesting the real element is a cap-4 composition question.

Four defects found by measuring, not by reading:

1. A CANVAS AT THE RIGHT SIZE IS NOT A MAP. With a <template> branch per subject
   kind, region -> country unmounted one map and mounted another into a
   zero-size container: 1200x780 canvas, correct centre and zoom, layers [],
   nothing drawn, a blank white card — and every assertion passed. One instance
   driven by props now, and the suite asserts queryRenderedFeatures (934 pins /
   51 outline for Africa, 61 / 5 for Cameroon) instead of canvas dimensions.

2. isStyleLoaded() IS THE WRONG GATE for adding layers. It also waits for the
   style's sources, which never arrive at zoom 6 on this box because the local
   token is URL-restricted and its high-zoom tiles 403. Same code drew a region
   and drew nothing for a country. Keyed on 'style.load' — the style being
   PARSED is what makes addSource/addLayer legal — with a parsed-style fallback.

3. THE PIN MODE WAS FROZEN at whatever it was when the layer was created, so a
   country page painted prayer colours. Caught in a screenshot by one yellow
   "1+ praying" pin; invisible to every assertion, because the pins were
   rendered and the legend was correctly absent. applySubject now re-applies
   circle-color and circle-sort-key.

4. THE PROFILE CONFIG ARRIVES BY INJECT, NOT AS A PROP. The element this bundle
   registers IS ProfileLoader, which parses the profile-config attribute and
   provides the parsed object (ProfileLoader.vue:195). Both tabs read it off
   their own string prop, which is always empty — prayBaseUrl, languageCode,
   instanceId and assetsBase all silently defaulted. Nothing looked broken until
   assetsBase mattered: the vendored Tabulator 404'd twice with no error and the
   country list just never drew. Fixed in both tabs, prop kept as a fallback.

Token delivery needs no wiring: the generator detects a map under the bundle and
bakes the ladder itself, so all three leaves now carry it and
check:nested-token passes.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 00:10:27 +00:00
parent 02fc4bd45c
commit 39b9a4cca9
9 changed files with 556 additions and 78 deletions

View file

@ -22,9 +22,11 @@
* config/countries-meta.ts. Copied rather than imported for the same reason stated there — and
* kept field-for-field identical so both tabs roll up the same way.
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import BarChart from '@map/components/charts/BarChart.vue'
import DataTable from '@map/components/tables/DataTable.vue'
import RegionCountryMap from './src/RegionCountryMap.vue'
const props = defineProps({
profileConfig: { type: String, default: '' },
@ -32,16 +34,40 @@ const props = defineProps({
languageCode: { type: String, default: '' },
})
// ★ THE CONFIG ARRIVES BY INJECT, NOT AS A PROP (measured 2026-09-23).
// The custom element this bundle registers IS ProfileLoader (index.js:41), which parses the
// profile-config ATTRIBUTE itself and hands the parsed object to its descendants via
// provide('profileConfig', config) (ProfileLoader.vue:195). So a profile component's own
// `profileConfig` STRING PROP is always empty, and every value read off it silently fell back
// to a default: prayBaseUrl, languageCode, instanceId and assetsBase alike. Nothing looked
// broken because the defaults happened to be right -- until assetsBase mattered, and the
// vendored Tabulator 404'd twice with no error while the table just never drew.
// Inject first, fall back to the prop so a direct <regions-dashboard profile-config='...'>
// outside ProfileLoader still works.
const injectedCfg = inject('profileConfig', null)
const cfg = computed(() => {
const inj = injectedCfg && (injectedCfg.value !== undefined ? injectedCfg.value : injectedCfg)
if (inj && typeof inj === 'object') return inj
if (!props.profileConfig) return {}
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
const instanceId = computed(() => cfg.value.instanceId || '')
// THE CAP-4 ASSET-PATH LESSON (BUG-UPG100MFE-ASSET-PATH, and it bit again here): DataTable
// injects the vendored Tabulator from a path RELATIVE to the document. Nested inside the
// regions-mfe composite the document is .../regions-mfe/index.html, so './assets' resolved to
// .../regions-mfe/assets/vendor/tabulator/* and 404'd twice with no visible error -- the table
// simply never drew. The generator bakes the right base per page depth and regions-mfe.vue
// already forwards it in INHERITED_PAGE_KEYS; this reads it instead of trusting the default.
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
// ── state ───────────────────────────────────────────────────────────────────
const regions = ref([])
// The rollup aggregates records away, and the map needs them one by one. Kept flat and
// pre-parsed once here rather than re-fetched inside the map child, as DX-life's CountryMap
// does -- one request per element, and the pins provably describe the same rows as the stats.
const points = ref([])
const loading = ref(true)
const loadFailed = ref(false)
// The current selection, as { regionSlug, countrySlug } — seeded from profile-config so a
@ -52,7 +78,10 @@ const sel = ref({
})
// ── identical field list + rollup to tab 1 ──────────────────────────────────
const FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
// A SUPERSET of tab 1's list: name + slug are added for the map popups and the country
// people-group list. Every ROLLUP field is unchanged, so the two tabs still roll up
// identically -- the extra fields are read by the map, never by addToStats().
const FIELDS = 'name,slug,country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
function toNumber(v) {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0)
@ -300,6 +329,85 @@ const summaryLine = computed(() => {
// regions so the page still compares like with like. The prompt below then says how to narrow it.
const isAll = computed(() => subject.value.kind === 'all')
// ── the map's subject: which countries to highlight, and how to frame them ──
// DX-life gets centre/zoom/bounds from config/countries-meta.ts, a build-time table we do not
// have. Deriving the frame from the FOCUS POINTS themselves is better, not a workaround: their
// own comment at CountryMap.vue:177-178 says bounds take precedence over centre/zoom because the
// frame should fit the real data rather than "the build-time approximation".
const focusCodes = computed(() => {
if (selectedCountry.value) return [selectedCountry.value.code]
if (selectedRegion.value) return selectedRegion.value.countries.map((c) => c.code)
return regions.value.flatMap((r) => r.countries.map((c) => c.code))
})
const mapBounds = computed(() => {
const focus = new Set(focusCodes.value)
const pts = points.value.filter((p) => focus.has(p.code))
if (pts.length < 2) return null
let w = Infinity, e = -Infinity, s2 = Infinity, n = -Infinity
for (const p of pts) {
if (p.lng < w) w = p.lng
if (p.lng > e) e = p.lng
if (p.lat < s2) s2 = p.lat
if (p.lat > n) n = p.lat
}
if (!Number.isFinite(w) || (w === e && s2 === n)) return null
return [[w, s2], [e, n]]
})
// Only used when bounds cannot be formed (a single people group, or none plotted yet).
const mapCenter = computed(() => {
const focus = new Set(focusCodes.value)
const pts = points.value.filter((p) => focus.has(p.code))
if (!pts.length) return [20, 10]
return [pts.reduce((a, p) => a + p.lng, 0) / pts.length, pts.reduce((a, p) => a + p.lat, 0) / pts.length]
})
const mapZoom = computed(() => (selectedCountry.value ? 4 : 2))
// Their map notes, verbatim, with the same %token% substitution their i18n uses.
// RegionDetail.vue:38-42 and CountryDetail.vue:29-33.
const mapNote = computed(() => {
if (selectedCountry.value) {
return 'There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.'
.replace('%count%', formatNumber(selectedCountry.value.count))
.replace('%country%', selectedCountry.value.name)
}
const label = subject.value.title
const countries = subject.value.kind === 'all' ? totalCountryCount.value : (selectedRegion.value?.countryCount || 0)
return '%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.'
.replace('%region%', label)
.replace('%count%', formatNumber(subject.value.stats.count))
.replace('%countries%', formatNumber(countries))
})
// ── the country page's people-group list ────────────────────────────────────
// CountryDetail.vue:63-69 nests UupgsList locked to the country. That component is 662 lines of
// search + eight filter dropdowns + photo cards, and this repo ALREADY ships that element as the
// people-groups-grid bundle -- re-implementing it here would be a third copy. So the country page
// gets the same INFORMATION through our own table pattern (@map/components/tables/DataTable.vue,
// which carries the mobile column-collapse and row-detail behaviour the Driver asked for once
// already). Nesting the real people-groups-grid element is the right end state and is a cap-4
// composition question, not a styling one.
const countryTable = computed(() => {
const c = selectedCountry.value
if (!c) return null
const rows = points.value
.filter((p) => p.code === c.code)
.sort((a, b) => (b.population - a.population) || a.name.localeCompare(b.name))
.map((p) => ({ name: p.name, population: p.population, committed: p.committed }))
return {
meta: { title: c.name + ' people groups' },
columns: [
{ field: 'name', title: 'People Group' },
{ field: 'population', title: 'Population', hozAlign: 'right', formatter: 'money',
formatterParams: { precision: 0, thousand: ',' } },
{ field: 'committed', title: 'People Praying', hozAlign: 'right', formatter: 'money',
formatterParams: { precision: 0, thousand: ',' } },
],
rows,
}
})
// ── in-dashboard navigation ─────────────────────────────────────────────────
// Their back-link (RegionDetail.vue:48) goes to /regions. We have no router, so it drops the
// selection, which lands on the ALL REGIONS subject -- the closest thing this element has to
@ -338,10 +446,17 @@ async function loadRegions() {
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
regions.value = summarizeRegions(posts)
points.value = posts.map((p) => ({
name: p.name || '', slug: p.slug || '',
code: p.country_code?.value || '', country: p.country_code?.label || '',
lng: parseFloat(p.longitude), lat: parseFloat(p.latitude),
committed: toNumber(p.people_committed), population: toNumber(p.population),
})).filter((p) => Number.isFinite(p.lng) && Number.isFinite(p.lat))
} catch (error) {
console.error('[region-dashboards] fetch error:', error)
loadFailed.value = true
regions.value = []
points.value = []
} finally {
loading.value = false
}
@ -434,6 +549,12 @@ useShadowStyles(
'.rd-stat__label{ margin:7.5188px 0 0; font-size:15.625px; color:var(--dx-muted); }' +
'.rd-chart{ padding:23.5264px clamp(17.689px, 4cqw, 31.2901px); }' +
// RegionDetail wraps the note and the map in a .stack--sm (13.3px). The note is their
// .text-center .color-primary .font-weight-bold -- --color-brand-primary at weight 700.
'.rd-mapblock{ display:flex; flex-direction:column; gap:13.3px; }' +
'.rd-mapnote{ margin:0; text-align:center; color:#92b195; font-weight:700; font-size:19.5312px; }' +
// .rounded-xlg on their <CountryMap>; the child takes border-radius:inherit from here.
'.rd-mapframe{ border-radius:var(--dx-radius); overflow:hidden; }' +
// ── .switcher[data-width="xl"]: composition/_switcher.scss. --switcher-width 70rem = 700px,
// gap --spacing-2xl (41.6158px). flex-basis calc((700px - 100%) * 999) is every-layout's
@ -523,41 +644,70 @@ useShadowStyles(
Region data could not be loaded. This is a connection problem, not a filter.
</div>
<!-- ★ ONE TEMPLATE, ONE MAP INSTANCE — not two branches each owning a <RegionCountryMap>.
MEASURED: with a branch per subject kind, switching region -> country UNMOUNTED one map
and MOUNTED another, and the new one initialised into a container that had no size yet.
It reported a 1200x780 canvas and drew NOTHING: layers [], m.loaded() false, a blank
white card. Every assertion I had passed, because "a canvas exists at size" is not the
same property as "the map drew something".
So the map is mounted ONCE here and re-framed by prop (applySubject), and the blocks
that genuinely differ between their two pages are the ones that come and go. DOM order
is chosen so each page reads in ITS OWN sequence with no CSS reordering:
region/all head · stats · map · chart · goals · table (RegionDetail.vue)
country head · map · people-group list (CountryDetail.vue) -->
<template v-else>
<!-- RegionDetail.vue:47-51 — back-link, title, summary, centred -->
<!-- RegionDetail.vue:47-51 / CountryDetail.vue:37-45 — back-link, title, summary -->
<div class="rd-head">
<p v-if="!isAll" class="rd-backlink">
<button type="button" class="rd-light-link" @click="clearSelection">&larr; All Regions</button>
</p>
<h1 class="rd-title">{{ subject.title }}</h1>
<p class="rd-summary">{{ summaryLine }}</p>
<p v-if="subject.kind === 'country'" class="rd-summary">
{{ formatNumber(subject.stats.count) }} People Groups &middot;
<button
type="button"
class="rd-light-link"
@click="selectFromTable({ kind: 'region', key: selectedRegion?.slug })"
>{{ subject.context }}</button>
</p>
<p v-else class="rd-summary">{{ summaryLine }}</p>
<p v-if="isAll" class="rd-prompt">
Pick a region or a country on the Regions List tab to narrow this dashboard.
</p>
</div>
<!-- RegionDetail.vue:53-70 — four stat cards -->
<div class="rd-stats">
<!-- RegionDetail.vue:53-70 — four stat cards. CountryDetail has none. -->
<div v-if="subject.kind !== 'country'" class="rd-stats">
<div v-for="st in stats" :key="st.label" class="rd-card rd-card--small rd-stat">
<p class="rd-stat__value" :data-len="lenBucket(st.value)">{{ st.value }}</p>
<p class="rd-stat__label">{{ st.label }}</p>
</div>
</div>
<!-- Stands where RegionDetail.vue:72-86 puts its CountryMap. The map itself is not ported
yet (it needs a per-region centre/zoom/bounds rollup and a token-bearing map child);
this comparison is the graphic until it lands. -->
<div class="rd-card rd-chart">
<BarChart
:series="chartRows"
:title="chartTitle"
unit="people groups"
color="#92b195"
/>
<!-- RegionDetail.vue:72-86 and CountryDetail.vue:47-61 — a .stack--sm of the note and the
map. color-by-prayer is theirs on the REGION page only; the country page passes it
not at all, which is what makes its pins read in-country vs surrounding. -->
<div class="rd-mapblock">
<p class="rd-mapnote">{{ mapNote }}</p>
<div class="rd-mapframe">
<RegionCountryMap
:country-codes="focusCodes"
:points="points"
:bounds="mapBounds"
:center="mapCenter"
:zoom="mapZoom"
:color-by-prayer="subject.kind !== 'country'"
/>
</div>
</div>
<!-- Ours, not theirs, and kept: the one view that RANKS the subject's parts. -->
<div v-if="subject.kind !== 'country'" class="rd-card rd-chart">
<BarChart :series="chartRows" :title="chartTitle" unit="people groups" color="#92b195" />
</div>
<!-- RegionDetail.vue:88-141 — the three DOXA goal cards in their .switcher -->
<div class="rd-switcher">
<div v-if="subject.kind !== 'country'" class="rd-switcher">
<div class="rd-card rd-card--secondary rd-goal-card">
<h2 class="rd-h2">Prayer Coverage</h2>
<div>
@ -585,9 +735,7 @@ useShadowStyles(
<p class="rd-big">{{ formatNumber(subject.stats.adopted) }} / {{ formatNumber(subject.stats.count) }}</p>
<p class="rd-big-label">people groups adopted</p>
</div>
<div class="rd-bar">
<div class="rd-bar__slider" :style="{ width: adoptedPct + '%' }" />
</div>
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: adoptedPct + '%' }" /></div>
<p class="rd-goal"><strong>Goal:</strong> Every unengaged people group adopted by a church committed to prayer, giving, and sending</p>
</div>
@ -597,15 +745,13 @@ useShadowStyles(
<p class="rd-big">{{ formatNumber(subject.stats.engaged) }} / {{ formatNumber(subject.stats.count) }}</p>
<p class="rd-big-label">people groups engaged</p>
</div>
<div class="rd-bar">
<div class="rd-bar__slider" :style="{ width: engagedPct + '%' }" />
</div>
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: engagedPct + '%' }" /></div>
<p class="rd-goal"><strong>Goal:</strong> No unengaged people groups by 2033</p>
</div>
</div>
<!-- RegionDetail.vue:144-171 — the five-column breakdown -->
<div class="rd-section">
<!-- RegionDetail.vue:144-171 — the five-column breakdown. CountryDetail has no table. -->
<div v-if="subject.kind !== 'country'" class="rd-section">
<h2 class="rd-h2 rd-h2--start">{{ tableTitle }}</h2>
<div class="rd-table-scroll">
<table class="rd-table">
@ -620,9 +766,7 @@ useShadowStyles(
</thead>
<tbody>
<tr v-for="row in tableRows" :key="row.key" :class="{ 'rd-tr--selected': row.isSelected }">
<td>
<button type="button" class="rd-row-link" @click="selectFromTable(row)">{{ row.name }}</button>
</td>
<td><button type="button" class="rd-row-link" @click="selectFromTable(row)">{{ row.name }}</button></td>
<td>{{ formatNumber(row.count) }}</td>
<td>{{ formatNumber(row.withPrayer) }}</td>
<td>{{ formatNumber(row.adopted) }}</td>
@ -632,6 +776,12 @@ useShadowStyles(
</table>
</div>
</div>
<!-- CountryDetail.vue:63-69 — UupgsList locked to the country, through our table pattern. -->
<div v-if="subject.kind === 'country' && countryTable" class="rd-section">
<h2 class="rd-h2 rd-h2--start">People groups in {{ subject.title }}</h2>
<DataTable :table="countryTable" :assets-base="assetsBase" />
</div>
</template>
</div>
</div>

View file

@ -29,7 +29,7 @@
* element (same double-emit pattern as people-groups-grid.vue's `groups-filtered`), so a plain
* host page hears it standalone and a Vue-nesting shell hears it as a prop-less DOM event.
*/
import { ref, computed, onMounted, getCurrentInstance } from 'vue'
import { inject, ref, computed, onMounted, getCurrentInstance } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
const props = defineProps({
@ -42,7 +42,20 @@ const props = defineProps({
const emit = defineEmits(['region-selected', 'country-selected'])
// profile-config JSON can override prayBaseUrl / languageCode (single string attr).
// ★ THE CONFIG ARRIVES BY INJECT, NOT AS A PROP (measured 2026-09-23).
// The custom element this bundle registers IS ProfileLoader (index.js:41), which parses the
// profile-config ATTRIBUTE itself and hands the parsed object to its descendants via
// provide('profileConfig', config) (ProfileLoader.vue:195). So a profile component's own
// `profileConfig` STRING PROP is always empty, and every value read off it silently fell back
// to a default: prayBaseUrl, languageCode, instanceId and assetsBase alike. Nothing looked
// broken because the defaults happened to be right -- until assetsBase mattered, and the
// vendored Tabulator 404'd twice with no error while the table just never drew.
// Inject first, fall back to the prop so a direct <regions-dashboard profile-config='...'>
// outside ProfileLoader still works.
const injectedCfg = inject('profileConfig', null)
const cfg = computed(() => {
const inj = injectedCfg && (injectedCfg.value !== undefined ? injectedCfg.value : injectedCfg)
if (inj && typeof inj === 'object') return inj
if (!props.profileConfig) return {}
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})

View file

@ -0,0 +1,306 @@
<script setup>
/**
* RegionCountryMap.vue — DX-life's CountryMap, rebuilt on the dx-maps map library.
*
* @description The map RegionDetail.vue:72-86 and CountryDetail.vue:47-61 both render: the focus
* countries filled and outlined, every people group plotted, the ones INSIDE the focus coloured
* and the surrounding ones muted so a viewer sees the area's groups in their neighbourhood.
*
* WHAT IS CLONED AND WHAT IS OURS. The LAYERS are DX-life's, expression for expression, from
* root-dx-life/doxa-life/app/components/public/CountryMap.vue:186-240 -- the country-boundaries
* fill at 0.12 opacity, the 2px outline, the worldview filter, the prayer/inCountry colour cases,
* the circle-sort-key that draws better-covered pins on top, the zoom-interpolated radius, the
* 0.75 opacity on neighbours. Their colours too (CountryMap.vue:64-71).
*
* The MACHINERY is ours. Theirs does `(await import('mapbox-gl')).default` and `new mapboxgl.Map`
* directly; that is a Nuxt page component with a document of its own. We are a custom element in a
* shadow root, where three things break and useMapInstance already solves all three: Mapbox's
* `container: 'id'` cannot cross a shadow boundary, its CSS <link> loads async and a map that
* initialises first gets a 0-height canvas, and the wordmark duplicates once per instance on a
* page composing several maps. So this takes the engine, the CSS timing, the resize and the token
* from @map/composables/useMapInstance.js and supplies only the layers.
*
* POINTS COME FROM THE PARENT, not a second fetch. Theirs re-fetches the whole people-groups list
* inside the map component (CountryMap.vue:91-93). region-dashboards.vue has already fetched every
* record to build its rollup, so passing them down keeps ONE request per element and guarantees
* the map and the figures above it describe the same data.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount, inject } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
const props = defineProps({
// ISO-3 codes to highlight, e.g. ['IND'] for a country or every code in a region.
countryCodes: { type: Array, default: () => [] },
// Every people group we know about: { name, slug, code, lng, lat, committed, country, population }.
points: { type: Array, default: () => [] },
// Colour the focus pins by prayer coverage and show the key (their `color-by-prayer`).
colorByPrayer: { type: Boolean, default: false },
center: { type: Array, default: () => [20, 10] },
zoom: { type: Number, default: 2 },
// [[west, south], [east, north]] — takes precedence over center/zoom, as theirs does.
bounds: { type: Array, default: null },
})
// The token ladder: ProfileLoader provides it (the same seam every map bundle uses). A nested
// element has no leaf page of its own, which is what check:nested-token exists to police.
const mapboxToken = inject('mapboxToken', ref(''))
const tokenValue = computed(() => (typeof mapboxToken === 'string' ? mapboxToken : mapboxToken?.value) || '')
// CountryMap.vue:64-71, resolved. brand = --color-brand (#3b463d) fill + outline; inCountry =
// green-100, brighter than the outline so pins read against the fill; muted = --color-brand-lighter;
// the three prayer colours are the Prayer Coverage card's own; outside = --color-brand-secondary-dark,
// warm grey, so neighbours do not compete with the dark "no one praying" pins.
const C = {
brand: '#3b463d',
muted: '#6d766e',
inCountry: '#73A17F',
prayerFull: '#92b195',
prayerPartial: '#f0c64a',
prayerNone: '#4e594f',
outside: '#a59d92',
}
const FULL_PRAYER_COVERAGE_COUNT = 100
const mapContainer = ref(null)
const mapFailed = ref(false)
const geojson = computed(() => {
const focus = new Set(props.countryCodes)
return {
type: 'FeatureCollection',
features: props.points
.filter((p) => Number.isFinite(p.lng) && Number.isFinite(p.lat))
.map((p) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
// JSON primitives only — Mapbox feature properties cannot hold nested objects.
properties: {
name: p.name || '', slug: p.slug || '', country: p.country || '',
inCountry: focus.has(p.code), committed: Number(p.committed) || 0,
population: Number(p.population) || 0,
},
})),
}
})
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
// A STRING, not the ref -- useMapInstance calls accessToken.startsWith('pk.') directly, so a
// ref reaches it as an object and the map dies with "t.startsWith is not a function". Every
// other bundle passes `.value` here (doxa-simple-map.vue:485); measured the hard way.
// An empty value is fine: the composable then falls through to window.MAP_TOKEN, which is the
// rung the generated leaf page sets -- that IS the token ladder.
accessToken: tokenValue.value,
style: 'mapbox://styles/mapbox/light-v11', // theirs, CountryMap.vue:174
center: props.center,
zoom: props.zoom,
maxZoom: 12,
// The map sits inside a scrolling dashboard, so the gray "scroll past me" strips would read as
// part of the page rather than as map chrome.
showEdgeBorders: false,
})
const SRC_PINS = 'rcm-people-groups'
const SRC_COUNTRIES = 'rcm-country-boundaries'
function countryFilter() {
// CountryMap.vue:187-191. The country-boundaries tileset carries one feature PER WORLDVIEW, so
// without this the highlight is drawn two or three times over.
return ['all',
['in', ['get', 'iso_3166_1_alpha_3'], ['literal', props.countryCodes]],
['any', ['==', ['get', 'worldview'], 'all'], ['in', 'US', ['get', 'worldview']]]]
}
// CountryMap.vue:218-223 verbatim. Split out of addLayers() because the LAYER is created once
// and the MODE changes underneath it: RegionDetail passes color-by-prayer, CountryDetail does
// not, and this element now keeps ONE map across both. Creating the layer with the region's
// expression and never updating it left a country page painting prayer colours -- a yellow
// "1+ praying" pin sitting on a page whose whole point is in-country vs surrounding. Nothing
// numeric caught it; it was visible in a screenshot and invisible to every assertion, because
// the pins WERE rendered and the legend WAS correctly absent.
function pinExpressions() {
const outsideFocus = ['!', ['get', 'inCountry']]
const full = ['>=', ['get', 'committed'], FULL_PRAYER_COVERAGE_COUNT]
const some = ['>', ['get', 'committed'], 0]
return {
color: props.colorByPrayer
? ['case', outsideFocus, C.outside, full, C.prayerFull, some, C.prayerPartial, C.prayerNone]
: ['case', ['get', 'inCountry'], C.inCountry, C.muted],
sortKey: props.colorByPrayer
? ['case', outsideFocus, 0, full, 3, some, 2, 1]
: ['case', ['get', 'inCountry'], 1, 0],
}
}
function addLayers() {
const m = map.value
if (!m) return
const { color: pinColor, sortKey: pinSortKey } = pinExpressions()
if (!m.getSource(SRC_COUNTRIES)) {
m.addSource(SRC_COUNTRIES, { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
m.addLayer({
id: 'rcm-focus-fill', type: 'fill', source: SRC_COUNTRIES,
'source-layer': 'country_boundaries', filter: countryFilter(),
paint: { 'fill-color': C.brand, 'fill-opacity': 0.12 },
})
m.addLayer({
id: 'rcm-focus-outline', type: 'line', source: SRC_COUNTRIES,
'source-layer': 'country_boundaries', filter: countryFilter(),
paint: { 'line-color': C.brand, 'line-width': 2 },
})
}
if (!m.getSource(SRC_PINS)) {
m.addSource(SRC_PINS, { type: 'geojson', data: geojson.value })
m.addLayer({
id: 'rcm-pins', type: 'circle', source: SRC_PINS,
layout: { 'circle-sort-key': pinSortKey },
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 6.5],
'circle-color': pinColor,
'circle-opacity': ['case', ['get', 'inCountry'], 1, 0.75],
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff',
},
})
m.on('click', 'rcm-pins', onPinClick)
m.on('mouseenter', 'rcm-pins', () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', 'rcm-pins', () => { m.getCanvas().style.cursor = '' })
}
}
// Their popup (CountryMap.vue:130-162) shows name, country, population and a profile link.
let popup = null
function onPinClick(e) {
const f = e.features && e.features[0]
const g = window.mapboxgl
if (!f || !g) return
const p = f.properties || {}
const pop = Number(p.population) || 0
const html = '<div style="font:14px/1.45 Poppins,sans-serif;color:#2a332a">' +
'<div style="font-weight:600;margin-bottom:2px">' + esc(p.name) + '</div>' +
(p.country ? '<div style="color:#6d766e">' + esc(p.country) + '</div>' : '') +
(pop ? '<div style="color:#6d766e">Population ' + pop.toLocaleString('en-US') + '</div>' : '') +
'<div style="color:#6d766e">' + (Number(p.committed) || 0).toLocaleString('en-US') + ' praying</div>' +
'</div>'
if (popup) popup.remove()
popup = new g.Popup({ closeButton: true, maxWidth: '264px', offset: 12 })
.setLngLat(f.geometry.coordinates).setHTML(html).addTo(map.value)
}
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// Re-frame and re-source on a new selection instead of tearing the map down: a rebuilt map
// re-downloads tiles and flashes, and the subject changes on every tab-1 click.
function applySubject() {
const m = map.value
// Deliberately NOT gated on isMapReady: that flag tracks useMapInstance's own view of the
// load, and gating on it is what let a re-frame land before the layers existed. getSource /
// getLayer answer the only question that matters here.
if (!m) return
const src = m.getSource(SRC_PINS)
if (src) src.setData(geojson.value)
// Re-apply the MODE, not just the data — see pinExpressions().
if (m.getLayer('rcm-pins')) {
const { color, sortKey } = pinExpressions()
m.setPaintProperty('rcm-pins', 'circle-color', color)
m.setLayoutProperty('rcm-pins', 'circle-sort-key', sortKey)
}
for (const id of ['rcm-focus-fill', 'rcm-focus-outline']) {
if (m.getLayer(id)) m.setFilter(id, countryFilter())
}
if (props.bounds) m.fitBounds(props.bounds, { padding: 40, maxZoom: 6, duration: 600 })
else m.flyTo({ center: props.center, zoom: props.zoom, duration: 600 })
}
/**
* Run `fn` once the STYLE is actually usable, with no dependence on when we started listening.
*
* ★ WHY NOT `map.once('load', ...)` — measured 2026-09-23, twice, in both directions.
* Whether 'load' has already fired by the time this component gets to subscribe depends on how
* long the style takes, and the answer changed when the element was restructured. Miss the
* event and addLayers() NEVER RUNS: the map is real, it has a 1200x780 GL canvas, it flies to
* the right centre and zoom, and it draws NOTHING. That state passed every check I had, because
* a canvas at the right size is not the same property as a map that drew something.
* So: ask the map whether it is ready (isStyleLoaded) rather than trusting that we saw the
* moment it became ready, and keep asking on every styledata until it says yes. Idempotent --
* addLayers() guards on getSource, so a duplicate call is a no-op.
*/
function whenStyleReady(m, fn) {
let done = false
const run = () => {
if (done) return
done = true
m.off('style.load', run); m.off('load', run)
fn()
}
// 'style.load' is the event that means "the style JSON is parsed, addSource/addLayer are legal".
// isStyleLoaded() is a STRONGER condition -- it also wants the style's sources loaded -- and
// waiting for it is a trap: MEASURED, the country view at zoom 6 never satisfies it on this box,
// because the local Mapbox token is URL-restricted and its high-zoom tiles 403. The region view
// at zoom 2.8 does satisfy it, so the same code drew 934 pins for a region and nothing at all
// for a country -- an environment limit masquerading as a bug in the country branch.
if (m.isStyleLoaded && m.isStyleLoaded()) return run()
m.on('style.load', run)
m.on('load', run)
// Last resort for exactly that case: if the style object already carries layers, the style is
// parsed and our layers can go on regardless of whether any tile ever arrives.
setTimeout(() => { try { if (m.getStyle && m.getStyle() && m.getStyle().layers) run() } catch (_) {} }, 1500)
}
onMounted(async () => {
try {
await initializeMap()
if (!map.value) { mapFailed.value = true; return }
whenStyleReady(map.value, () => { addLayers(); applySubject() })
} catch (e) {
console.error('[RegionCountryMap] init failed:', e)
mapFailed.value = true
}
})
// A CANVAS AT THE RIGHT SIZE IS NOT A MAP. Exposed so a gate can call
// queryRenderedFeatures() and assert pins and the country fill were actually DRAWN -- the
// country map rendered fully blank while 'a real GL canvas exists at 1200x780' passed.
// Same seam verify:interop reads for the camera (window.__rdashMap).
watch(isMapReady, (r) => { if (r && typeof window !== 'undefined') window.__rcmMap = map.value })
onBeforeUnmount(() => { if (popup) popup.remove(); try { delete window.__rcmMap } catch (_) {} destroy() })
watch(() => [props.countryCodes, props.bounds, props.center, props.zoom, props.points, props.colorByPrayer], applySubject, { deep: true })
// Their .country-map block, CountryMap.vue's scoped style: 16/9 with a 780px floor, flipping to
// 1/2 and no floor under 768px. Ported as an aspect-ratio pair; radius:inherit so the card that
// contains it supplies the corner, exactly as theirs does.
useShadowStyles(
'.rcm{ display:block; position:relative; width:100%; min-height:780px; aspect-ratio:16/9;' +
' overflow:hidden; border-radius:inherit; }' +
'@media (max-width:768px){ .rcm{ min-height:0; aspect-ratio:1/2; } }' +
'.rcm__canvas{ position:absolute; inset:0; }' +
'.rcm__note{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center;' +
' text-align:center; padding:24px; color:#6d766e; font-size:15.625px; background:#f3f3f1; }' +
// Their key, overlaid bottom-left above the canvas (CountryMap.vue's .country-map__legend).
'.rcm__legend{ position:absolute; left:10px; bottom:28px; z-index:2; display:flex;' +
' flex-direction:column; gap:4px; padding:8px 10px; border-radius:8px;' +
' background:rgba(255,255,255,0.92); font-size:12.5px; font-weight:600; color:#2a332a; }' +
'.rcm__legend span{ display:inline-flex; align-items:center; gap:6px; }' +
'.rcm__legend i{ width:10px; height:10px; border-radius:100px; flex:0 0 auto; }',
'region-country-map'
)
</script>
<template>
<div class="rcm">
<div ref="mapContainer" class="rcm__canvas" />
<p v-if="mapFailed" class="rcm__note" role="status">
The map could not start. This is a map-token or connection problem, not a filter — the
figures above are unaffected.
</p>
<div v-if="colorByPrayer && !mapFailed" class="rcm__legend">
<span><i :style="{ background: C.prayerFull }" />100+ People Praying</span>
<span><i :style="{ background: C.prayerPartial }" />1+ People Praying</span>
<span><i :style="{ background: C.prayerNone }" />No One Praying</span>
<span><i :style="{ background: C.outside }" />Outside this region</span>
</div>
</div>
</template>

File diff suppressed because one or more lines are too long

View file

@ -821,7 +821,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-h/IRguvZlY/GU6x5jBfMHgY70Qq+zOEs1dQGt/SjDQK7iq4Aband8NqXtxyc5RSc",
"integrity": "sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko",
"elements": [
"regions-dashboard"
],
@ -836,12 +836,12 @@
"description": "The dashboard half of the Driver's 2-tab Regions element: given a region (or a",
"element": "regions-dashboard",
"type": "single",
"isMap": false,
"isMap": true,
"iframeUrl": "./regions-dashboard/region-dashboards/index.html",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-h/IRguvZlY/GU6x5jBfMHgY70Qq+zOEs1dQGt/SjDQK7iq4Aband8NqXtxyc5RSc\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -850,12 +850,12 @@
"description": "A refactor of DX-life's /regions index page (app/pages/regions/index.vue) into",
"element": "regions-dashboard",
"type": "single",
"isMap": false,
"isMap": true,
"iframeUrl": "./regions-dashboard/regions-list/index.html",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-h/IRguvZlY/GU6x5jBfMHgY70Qq+zOEs1dQGt/SjDQK7iq4Aband8NqXtxyc5RSc\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -864,12 +864,12 @@
"description": "The composite the Driver asked for: ONE element carrying the SELECT VIEW switcher",
"element": "regions-dashboard",
"type": "nested-elements-mfe",
"isMap": false,
"isMap": true,
"iframeUrl": "./regions-dashboard/regions-mfe/index.html",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-h/IRguvZlY/GU6x5jBfMHgY70Qq+zOEs1dQGt/SjDQK7iq4Aband8NqXtxyc5RSc\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>region-dashboards · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-h/IRguvZlY/GU6x5jBfMHgY70Qq+zOEs1dQGt/SjDQK7iq4Aband8NqXtxyc5RSc" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +48,7 @@
var BASE_CONFIG = {"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

File diff suppressed because one or more lines are too long

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>regions-list · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-h/IRguvZlY/GU6x5jBfMHgY70Qq+zOEs1dQGt/SjDQK7iq4Aband8NqXtxyc5RSc" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +48,7 @@
var BASE_CONFIG = {"profile":"regions-list","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-list","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>regions-mfe · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
@ -33,11 +36,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-h/IRguvZlY/GU6x5jBfMHgY70Qq+zOEs1dQGt/SjDQK7iq4Aband8NqXtxyc5RSc" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-Hn8cIgvuGllKRntlUlVP5AWhd8OspkcZeqrN0FH31JTdvKkBIRDsmCDZf7m4Fnko" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -56,7 +59,7 @@
var BASE_CONFIG = {"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-mfe","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts