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:
parent
02fc4bd45c
commit
39b9a4cca9
9 changed files with 556 additions and 78 deletions
|
|
@ -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">← 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 ·
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 {} }
|
||||
})
|
||||
|
|
|
|||
306
plugin-designs/regions-dashboard/src/RegionCountryMap.vue
Normal file
306
plugin-designs/regions-dashboard/src/RegionCountryMap.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 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>
|
||||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
14
public/doxa-maps/manifest.json
generated
14
public/doxa-maps/manifest.json
generated
|
|
@ -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>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue