doxa-maps/plugin-designs/regions-dashboard/src/RegionCountryMap.vue
doxa-maps-88655c70 cb75ba38b5 Regions map: country boundaries load on MapLibre too
Caught by spot-checking after main moved, not by the sweep. RegionCountryMap
added its country polygons from mapbox://mapbox.country-boundaries-v1, a
Mapbox-hosted VECTOR TILESET that MapLibre cannot fetch:

    Fetch API cannot load mapbox://mapbox.country-boundaries-v1.
    URL scheme "mapbox" is not supported

The basemap then drew perfectly with the highlighted countries simply
absent. My own sweep called this leaf "SWAPPED" because it counts canvases,
engines and request totals -- all of which were right. A coarse metric can
be green while the primary data layer is missing; only the console said so.

Fixed by the pattern DashboardMapPanel already proved, copied rather than
reinvented: under maplibre the source is geojson, fed from the keyless
assets/boundaries/world-adm0.geojson the tree already ships and three other
plugins already consume through resolveAssetsBase. Added EMPTY first and
setData after the fetch, so the addLayer calls stay synchronous and the
fills remain beneath the pins. The tileset names the ISO field
iso_3166_1_alpha_3 and the geojson names it iso3, so the property is
normalised ONCE at load and countryFilter() keeps reading the single name it
always read -- the Mapbox path is byte-for-byte unchanged. 'source-layer' is
set for Mapbox alone; MapLibre rejects a layer that declares one on geojson.

MEASURED via the PUBLIC querySourceFeatures (not a private field -- that
lesson cost two reverts earlier in this flight):
  mapbox    400 boundary features, 0 console errors
  maplibre  467 boundary features, 0 console errors   (was 1 scheme error)
Both engines expose the same three layers.

gates:pre 0. gates:post 17/18, the single failure being the pre-existing
verify/tables-complete break from the visa-leak fix (filed, not mine).
2026-09-24 03:34:19 +00:00

430 lines
23 KiB
Vue

<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 { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// The region palette comes from the shared strategy, never from a table in here:
// check:one-palette exists because a private palette in a consumer is what once made two
// surfaces disagree about what a colour meant.
import { applyColor as regionColorExpression } from '@map/colors/wagf-region.js'
const emit = defineEmits(['pick-country'])
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: () => [] },
// HOW to colour the pins. Three modes, and only one of them is ours:
// 'prayer' — DX-life's color-by-prayer, what RegionDetail passes
// 'in-country' — DX-life's default, what CountryDetail passes (in-country vs surrounding)
// 'region' — OURS, and only for the no-selection chooser: paints every pin by its WAGF
// region so the seven read AS regions on load. Never used on a cloned page.
colorMode: { type: String, default: 'in-country' },
// Chooser only: lift one region's pins and recede the rest, on legend hover.
highlightRegion: { type: String, default: '' },
// Chooser only: a pin click selects, instead of opening their popup.
selectOnPinClick: { 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 profileConfig = inject('profileConfig', ref(null))
// Which engine is live. Derived ONCE: addLayers() branches on it in three places, and three
// separate resolveMapProvider() calls is three chances for them to disagree.
const isMaplibreProvider = resolveMapProvider(profileConfig) === 'maplibre'
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,
region: p.region || '', code: p.code || '',
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]
const lit = props.highlightRegion
const isLit = ['==', ['get', 'region'], lit]
if (props.colorMode === 'region') {
// OURS, chooser only. Colour from the shared strategy; a hovered region lifts above the rest
// and the others recede, which is the cheapest way to say "these pins are one group".
return {
color: regionColorExpression(),
sortKey: lit ? ['case', isLit, 2, 1] : 1,
opacity: lit ? ['case', isLit, 1, 0.25] : 0.9,
radius: lit
? ['case', isLit, ['interpolate', ['linear'], ['zoom'], 2, 4.5, 6, 8], ['interpolate', ['linear'], ['zoom'], 2, 2.5, 6, 5]]
: ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 6.5],
}
}
// DX-life's two modes, unchanged (CountryMap.vue:218-223).
return {
color: props.colorMode === 'prayer'
? ['case', outsideFocus, C.outside, full, C.prayerFull, some, C.prayerPartial, C.prayerNone]
: ['case', ['get', 'inCountry'], C.inCountry, C.muted],
sortKey: props.colorMode === 'prayer'
? ['case', outsideFocus, 0, full, 3, some, 2, 1]
: ['case', ['get', 'inCountry'], 1, 0],
opacity: ['case', ['get', 'inCountry'], 1, 0.75],
radius: ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 6.5],
}
}
function addLayers() {
const m = map.value
if (!m) return
const { color: pinColor, sortKey: pinSortKey, opacity: pinOpacity, radius: pinRadius } = pinExpressions()
if (!m.getSource(SRC_COUNTRIES)) {
// ── COUNTRY POLYGONS, PER PROVIDER ────────────────────────────────────────────────────────
// ★ THE ENGINE SWAP IS NOT ONE CONFIG LINE. This read from
// `mapbox://mapbox.country-boundaries-v1`, a Mapbox-hosted VECTOR TILESET that MapLibre
// cannot fetch at all — it throws
// Fetch API cannot load mapbox://mapbox.country-boundaries-v1. URL scheme "mapbox" is not supported
// and then draws the basemap perfectly with the highlighted countries simply ABSENT.
// MEASURED, not predicted: the leaf reported canvas=1 and engine=MAPLIBRE — a coarse sweep
// called it "swapped" while its primary data layer was missing. Only the console said so.
//
// The keyless equivalent already ships: assets/boundaries/world-adm0.geojson, staged by
// generate-tree and already consumed by upg-100-list, connectivity-map and resources-map
// through this same resolveAssetsBase helper. No new asset, no new dependency.
//
// ★ THE FIELD BRIDGE. The tileset names the ISO field `iso_3166_1_alpha_3`; the geojson names
// it `iso3`. countryFilter() reads the tileset name, so the property is normalised ONCE as the
// data loads and every expression keeps reading the single name it always read — the Mapbox
// path stays byte-for-byte unchanged. Same shape as DashboardMapPanel, deliberately copied.
if (isMaplibreProvider) {
// Add EMPTY first, then setData after the fetch: the addLayer calls below must run
// SYNCHRONOUSLY to keep these fills beneath the pins (layer order is insertion order).
m.addSource(SRC_COUNTRIES, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
fetch(`${resolveAssetsBase(profileConfig?.value)}/boundaries/world-adm0.geojson`)
.then((r) => r.json())
.then((gj) => {
for (const f of gj.features || []) {
if (f.properties && f.properties.iso3) f.properties.iso_3166_1_alpha_3 = f.properties.iso3
}
const src = map.value && map.value.getSource(SRC_COUNTRIES)
if (src) src.setData(gj)
})
.catch((e) => console.warn('[rcm] country boundaries failed to load', e))
} else {
m.addSource(SRC_COUNTRIES, { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
}
// 'source-layer' applies to VECTOR TILE sources only — on a geojson source MapLibre rejects
// the layer outright ("does not need a source-layer"), so it is set for Mapbox alone.
const srcLayer = isMaplibreProvider ? {} : { 'source-layer': 'country_boundaries' }
m.addLayer({
id: 'rcm-focus-fill', type: 'fill', source: SRC_COUNTRIES,
...srcLayer, filter: countryFilter(),
paint: { 'fill-color': C.brand, 'fill-opacity': 0.12 },
})
m.addLayer({
id: 'rcm-focus-outline', type: 'line', source: SRC_COUNTRIES,
...srcLayer, 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': pinRadius,
'circle-color': pinColor,
'circle-opacity': pinOpacity,
'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]
if (!f) return
// CHOOSER: a pin click is a CHOICE, not a popup — the whole point of the state is to pick a
// subject. On a cloned page it stays their popup, because that is what their page does.
if (props.selectOnPinClick) {
const p = f.properties || {}
emit('pick-country', { code: p.code || '', region: p.region || '', name: p.name || '' })
return
}
const g = window.mapboxgl
if (!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.
/**
* The chooser's zoom has to follow the CONTAINER WIDTH, not be a constant.
*
* MEASURED: a fixed zoom of 1.25 fills a 1200px desktop frame and shows barely a third of the
* world at 390px — Asia fell off the right edge, on the one screen whose job is "pick a region".
* Mercator's world is 512*2^z wide, so the zoom that fits it is log2(width/512): ~1.23 at 1200px,
* ~-0.39 at 390px. Mapbox will not go below 0, so a phone still crops, but ~24% instead of ~67%,
* and the map pans. Clamped to [0, 2] so a very wide embed does not zoom past the useful range.
* Only the chooser uses this; a selected page is framed by their own bounds/centre/zoom.
*/
function chooserZoom(m) {
if (props.colorMode !== 'region') return props.zoom
let w = 0
try { w = m.getContainer()?.clientWidth || 0 } catch (_) { w = 0 }
if (!w) return props.zoom
return Math.max(0, Math.min(2, Math.log2(w / 512)))
}
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, opacity, radius } = pinExpressions()
m.setPaintProperty('rcm-pins', 'circle-color', color)
m.setPaintProperty('rcm-pins', 'circle-opacity', opacity)
m.setPaintProperty('rcm-pins', 'circle-radius', radius)
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())
}
// PROJECTION follows the mode, and only the chooser changes it.
// Mapbox v3 defaults to the GLOBE below about zoom 6, so a world-view chooser rendered as a
// small sphere floating in a 1200x780 frame — most of the surface empty, the regions tiny.
// Mercator fills the frame and reads as a world map, which is what a chooser wants.
// The CLONED pages are deliberately left alone: DX-life sets no projection either, so their
// region page shows the same globe at the same zoom, and matching them is the whole point.
// setProjection is the house pattern for this (doxa-simple-map.vue:1241, all-people-groups,
// demo-map, poster-wall all do it).
try { if (props.colorMode === 'region') m.setProjection('mercator') } catch (_) {}
if (props.bounds) m.fitBounds(props.bounds, { padding: 40, maxZoom: 6, duration: 600 })
else m.flyTo({ center: props.center, zoom: chooserZoom(m), 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(); watchResize() })
} 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 })
// A rotated phone or a resized pane changes the width the zoom was derived from.
let ro = null
function watchResize() {
if (typeof ResizeObserver === 'undefined' || !mapContainer.value) return
ro = new ResizeObserver(() => {
const m = map.value
if (!m || props.colorMode !== 'region' || props.bounds) return
try { m.setZoom(chooserZoom(m)) } catch (_) {}
})
ro.observe(mapContainer.value)
}
onBeforeUnmount(() => { if (ro) { try { ro.disconnect() } catch (_) {} } if (popup) popup.remove(); try { delete window.__rcmMap } catch (_) {} destroy() })
watch(() => [props.countryCodes, props.bounds, props.center, props.zoom, props.points, props.colorMode, props.highlightRegion], 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="colorMode === 'prayer' && !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>