A country map you can drop on one page, without the dashboard around it

DX-life's country pages render nothing where a map belongs. The map they need
has been in this repo since the regions clone landed — it was just unreachable
without loading the whole regions dashboard, a bundle that fetches the people
group list, builds four stat cards, a region chooser and a table, none of which
a country page wants. So this is not a new map. It is the same component, given
its own element and its own bundle.

  <doxa-country-map profile-config='{"countryCodes":["TCD"],"colorByPrayer":true}'>

RegionCountryMap and useRegionsI18n move to library/ because they now have a
second consumer, which is the only thing that earns a place there. Six import
sites updated in this commit; nothing else moved.

THE CONTRACT arrives through profile-config, the channel every element here
uses, so a generated page and a hand-written host page are configured the same
way: countryCodes, center, zoom, bounds, colorByPrayer, researchUrl,
prayBaseUrl. Two translations happen at the edge rather than by widening the
component — colorByPrayer is a boolean here and colorMode a string inside, and
countryCodes accepts an array OR "TCD,NER,MLI", because an HTML attribute is a
string and JSON-inside-an-attribute is the shape people get wrong.

THE ELEMENT FETCHES ITS OWN PINS, and that is what makes prayBaseUrl part of the
contract rather than decoration. An element handed a country code, a base URL
and no way to fetch would draw a country outline with nothing inside it — the
blank page it exists to fix, in a new disguise. Same endpoint, same fields, same
mapping as the dashboard; it asks for a country instead of the world.

THE FIELD LIST TRAVELS WITH THE MAPPING, in peopleGroupPoints.js. The obvious
version of this commit asks for PEOPLE_GROUP_LIST_FIELDS — the list the stats
need — and every pin still draws, every popup comes up empty, and nothing logs
anything. The popup half is what makes it a map you can read, so the two are
one export and neither can be requested without the other. The dashboard's own
request is unchanged: the concatenation produces the identical string.

countryUrl is additive, not in the confirmed contract. The component's country
link needs a base and DX-life's country pages live under /regions/; a host that
sets nothing gets that documented default.
This commit is contained in:
Doxa Maps 2026-09-28 22:00:18 +00:00
parent 6956fd790f
commit 2225e2b4bc
19 changed files with 1153 additions and 222 deletions

View file

@ -46,7 +46,7 @@ import { applyColor as regionColorExpression, PALETTE as REGION_PALETTE, getColo
// whose every other word is translated into eleven languages, and three of those four were
// ALREADY in the catalogue — translated for the Prayer Coverage card and hardcoded here.
// Their CountryMap.vue calls t() for the same strings (:268-272).
import { useRegionsI18n } from './useRegionsI18n.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
import { joinBase } from '@map/composables/joinBase.js'

View file

@ -0,0 +1,37 @@
/**
* peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
*
* ONE mapping from the prayer API's people-group rows to the pin objects RegionCountryMap draws.
* Extracted when the country-map bundle became the second consumer, rather than copied: the field
* names here are not ours, they are the API's, and a copy that drifts by one key draws a map with
* silently missing popups.
*
* Every field is mapped exactly as DX-life's CountryMap.vue:107-115 maps it, including the
* has_photo GATE on the image, which is theirs: a row can carry an image_url that is a
* placeholder, and they only show it when the flag says there is a real photo.
*
* Rows without finite coordinates are DROPPED, not defaulted — a pin at 0,0 is a people group in
* the Gulf of Guinea, which is worse than an absent pin because it looks like data.
*/
/**
* The fields this mapping needs, as the API spells them. It lives HERE, beside the mapping, because
* the two are one contract: ask for PEOPLE_GROUP_LIST_FIELDS alone — the list the stats need — and
* every pin still draws, every popup comes up empty, and nothing logs a thing. The popup half
* (name, slug, rop1, religion, description, photo) is what makes it a map you can read.
*/
export const PEOPLE_GROUP_POINT_FIELDS = 'name,slug,rop1,religion,imb_people_description,image_url,has_photo'
export function peopleGroupPoints(posts, toNumber) {
return (Array.isArray(posts) ? posts : []).map((p) => ({
name: p.name || '', slug: p.slug || '',
code: p.country_code?.value || '', country: p.country_code?.label || '',
// the wagf_region VALUE (e.g. 'latin_america_&_caribbean'), which is what
// library/colors/wagf-region.js keys its palette on
region: p.wagf_region?.value || '',
lng: parseFloat(p.longitude), lat: parseFloat(p.latitude),
committed: toNumber(p.people_committed), population: toNumber(p.population),
rop1: p.rop1?.label || '', religion: p.religion?.label || '',
description: p.imb_people_description ?? '',
imageUrl: (p.has_photo && p.image_url) ? p.image_url : '',
})).filter((p) => Number.isFinite(p.lng) && Number.isFinite(p.lat))
}

View file

@ -0,0 +1,5 @@
{
"label": "Country Map",
"stage": "production",
"_comment": "Production: DX-life's country pages are the consumer this bundle was cut for, and they are live pages."
}

View file

@ -0,0 +1,125 @@
<script setup>
/**
* country-map.vue — the standalone country map, as its own element.
*
* @element doxa-country-map
*
* ★ WHY THIS BUNDLE EXISTS. DX-life's country pages render nothing where a map belongs. The map
* they need has been in this repo since the regions clone landed, but it was reachable only by
* loading the whole regions dashboard — a bundle that fetches the people-group list, builds four
* stat cards, a region chooser and a table, none of which a country page wants. So this is not a
* new map. It is the SAME component (now at @map/components/maps/RegionCountryMap.vue) given its
* own element and its own bundle, so a host can drop one tag on a country page.
*
* THE CONTRACT, as confirmed: countryCodes, center, zoom, bounds, colorByPrayer, researchUrl,
* prayBaseUrl. Every one arrives through profile-config, the same channel every other element in
* this repo uses, so a generated page and a hand-written host page are configured identically.
*
* colorByPrayer is a BOOLEAN in the contract and colorMode is a STRING inside the component.
* That is a translation, not a rename, and it is done here rather than by widening the component:
* a host asking for prayer colouring should not have to know the vocabulary of the mode registry.
*
* countryUrl is additive and not in the confirmed contract. It is here because the component's
* country link needs a base and DX-life's country pages live under /regions/ — a host that does
* not set it gets exactly the documented default.
*/
import { computed, inject, ref, onMounted, watch } from 'vue'
import RegionCountryMap from '@map/components/maps/RegionCountryMap.vue'
import { toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
import { peopleGroupPoints, PEOPLE_GROUP_POINT_FIELDS } from '@map/utils/peopleGroupPoints.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
const props = defineProps({
profileConfig: { type: String, default: '' },
})
// ★ THE CONFIG ARRIVES BY INJECT, NOT AS A PROP. The custom element this bundle registers IS
// ProfileLoader, which parses the profile-config ATTRIBUTE and provides the parsed object to its
// descendants; a profile's own profileConfig string prop is always empty under it. Inject first,
// fall back to the prop so a direct <doxa-country-map profile-config='...'> outside ProfileLoader
// still works. Measured on region-dashboards 2026-09-23, where reading the prop silently fell
// back to defaults that happened to be right until one of them was not.
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 {} }
})
// A host may write country-codes as an array or as "TCD,NER,MLI" — an HTML attribute is a string,
// and JSON inside an attribute inside JSON is the shape people get wrong. Both are accepted.
const countryCodes = computed(() => {
const v = cfg.value.countryCodes
if (Array.isArray(v)) return v.filter(Boolean).map((c) => String(c).toUpperCase())
if (typeof v === 'string') return v.split(',').map((c) => c.trim().toUpperCase()).filter(Boolean)
return []
})
const center = computed(() => (Array.isArray(cfg.value.center) ? cfg.value.center : null))
const bounds = computed(() => (Array.isArray(cfg.value.bounds) ? cfg.value.bounds : null))
const zoom = computed(() => (Number.isFinite(Number(cfg.value.zoom)) ? Number(cfg.value.zoom) : 2))
// The contract's boolean, translated into the component's mode vocabulary at the one place that
// knows both. false/absent keeps the component's own default rather than naming it again here.
const colorMode = computed(() => (cfg.value.colorByPrayer ? 'prayer' : undefined))
const researchUrl = computed(() => cfg.value.researchUrl || 'https://doxa.life/research/')
const prayBaseUrl = computed(() => cfg.value.prayBaseUrl || 'https://pray.doxa.life')
const countryUrl = computed(() => cfg.value.countryUrl || 'https://doxa.life/regions/')
const languageCode = computed(() => cfg.value.languageCode || cfg.value.locale || cfg.value.lang || 'en')
const { t } = useRegionsI18n()
// ★ THE ELEMENT FETCHES ITS OWN PINS. prayBaseUrl is in the contract, and it is the API base the
// rest of this repo reads people groups from — an element given only a country code and a base URL
// and no way to fetch would draw a country outline with nothing in it, which is the blank page it
// exists to fix. Same endpoint, same fields and same mapping the regions dashboard uses; the only
// difference is that this one asks for a country rather than the world.
const points = ref([])
const loading = ref(true)
const loadFailed = ref(false)
async function loadPoints() {
loading.value = true
loadFailed.value = false
try {
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=${PEOPLE_GROUP_POINT_FIELDS},${PEOPLE_GROUP_LIST_FIELDS}&lang=${languageCode.value}`
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const all = peopleGroupPoints(Array.isArray(data.posts) ? data.posts : [], toNumber)
// Filter HERE rather than asking the API for one country: the endpoint returns the whole list
// and every other consumer in this repo filters client-side, so a country page and a dashboard
// showing the same country cannot disagree about which groups are in it.
const want = new Set(countryCodes.value)
points.value = want.size ? all.filter((p) => want.has(String(p.code).toUpperCase())) : all
} catch (error) {
console.error('[country-map] fetch error:', error)
loadFailed.value = true
points.value = []
} finally {
loading.value = false
}
}
onMounted(loadPoints)
// A locale change REFETCHES: the popup's labels — country, religion, people-group names — come
// from the API's ?lang=, not from our catalogue, so re-rendering alone leaves them in the old
// language. Measured on the dashboard, where exactly this left every country name in English.
watch(() => [languageCode.value, countryCodes.value.join(',')], loadPoints)
</script>
<template>
<RegionCountryMap
:points="points"
:country-codes="countryCodes"
:center="center"
:bounds="bounds"
:zoom="zoom"
:color-mode="colorMode"
:research-url="researchUrl"
:pray-base-url="prayBaseUrl"
:country-url="countryUrl"
/>
<p v-if="loadFailed" class="cm-status" role="status">{{ t('Region data could not be loaded. This is a connection problem, not a filter.') }}</p>
</template>

View file

@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-country-maps · country-map staging</title>
<link rel="icon" href="data:,">
<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>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: #0b0f14; color: #e5e7eb; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ── Hero: Vite lightning inside a file silhouette + glowing beams ── */
.vite-hero {
position: relative; display: flex; flex-direction: column; align-items: center;
gap: 14px; padding: 32px 16px 20px; overflow: hidden;
}
.vite-filestack {
position: relative;
width: 108px; height: 128px;
display: flex; align-items: center; justify-content: center;
}
.vite-file {
position: absolute; inset: 0; width: 100%; height: 100%;
animation: file-pulse 3.2s ease-in-out infinite;
}
.vite-file path.body { fill: rgba(65, 209, 255, 0.06); stroke: rgba(65, 209, 255, 0.55); stroke-width: 2; }
.vite-file path.fold { fill: rgba(65, 209, 255, 0.14); stroke: rgba(65, 209, 255, 0.55); stroke-width: 2; }
.vite-file text { fill: rgba(185, 166, 245, 0.6); font: 700 9px/1 ui-monospace, monospace; letter-spacing: .12em; }
@keyframes file-pulse {
0%, 100% { filter: drop-shadow(0 0 6px rgba(65, 209, 255, 0.25)); }
50% { filter: drop-shadow(0 0 16px rgba(65, 209, 255, 0.55)); }
}
.vite-hero img {
position: relative; z-index: 1;
width: 56px; height: 56px;
animation: vite-pulse 2.8s ease-in-out infinite;
}
@keyframes vite-pulse {
0%, 100% { filter: drop-shadow(0 0 8px rgba(100,200,255,.45)); transform: translateY(0); }
50% { filter: drop-shadow(0 0 20px rgba(100,200,255,.95)) drop-shadow(0 0 5px rgba(185,166,245,.7)); transform: translateY(-2px); }
}
.vite-hero .caption {
font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
color: #94a3b8; letter-spacing: .14em; text-transform: uppercase; text-align: center;
}
.vite-hero .caption strong { color: #e5e7eb; }
.vite-beams { position: relative; width: 100%; max-width: 520px; height: 60px; pointer-events: none; }
.vite-beams svg { width: 100%; height: 100%; overflow: visible; }
.vite-beams line {
stroke: url(#beamGradient); stroke-width: 1.5; stroke-linecap: round;
stroke-dasharray: 4 6; opacity: .75;
animation: beam-flow 1.6s linear infinite;
}
.vite-beams line:nth-child(even) { animation-duration: 2s; }
.vite-beams line:nth-child(3n) { animation-duration: 1.3s; }
@keyframes beam-flow { from { stroke-dashoffset: 20; } to { stroke-dashoffset: 0; } }
/* ── Topbar: profile tabs + Overview button ── */
.topbar {
position: sticky; top: 0; z-index: 100;
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding: 10px 16px; background: #111827; border-bottom: 1px solid #1f2937;
}
.topbar .spacer { flex: 1; }
.profile-tab {
padding: 8px 16px;
background: transparent; border: 1px solid #334155; color: #94a3b8;
font: 700 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: .06em; text-transform: uppercase;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.profile-tab:hover { border-color: #64748b; color: #e5e7eb; }
.profile-tab.active {
border-color: #60a5fa; color: #60a5fa; background: rgba(96,165,250,.08);
}
.overview-btn {
padding: 8px 14px;
background: transparent; border: 1px solid #334155; color: #94a3b8;
font: 600 11px/1 ui-monospace, monospace;
letter-spacing: .06em; text-transform: uppercase; cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.overview-btn:hover { border-color: #64748b; color: #e5e7eb; }
.overview-btn.active { border-color: #60a5fa; color: #60a5fa; background: rgba(96,165,250,.08); }
/* ── Showcase ── */
#showcase {
display: flex; flex-direction: column; gap: 24px;
padding: 20px; max-width: 1500px; margin: 0 auto;
}
/* ── Gem frame (per-slot accent; JS sets --gem + --gem-rgb) ── */
.gem-frame {
--fw: 14px; --gem: #39ff80; --gem-rgb: 57, 255, 128;
position: relative; padding: var(--fw);
background: #1a2420;
border: 2px solid var(--gem);
box-shadow: 0 0 20px rgba(var(--gem-rgb), .12), inset 0 0 20px rgba(var(--gem-rgb), .04);
transition: box-shadow .3s, transform .4s cubic-bezier(.22, 1, .36, 1);
isolation: isolate;
}
.gem-frame:hover { box-shadow: 0 0 35px rgba(var(--gem-rgb), .2), inset 0 0 30px rgba(var(--gem-rgb), .06); }
.gem-frame-content { border: 2px solid var(--gem); overflow: hidden; background: #142118; }
.gem-bevel-svg { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 2; pointer-events: none; overflow: visible; }
.gem-bevel-svg line { stroke: var(--gem); stroke-width: 1.5; opacity: .7; }
.gem-frame:hover .gem-bevel-svg line { opacity: 1; }
.gem-header {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
background: rgba(var(--gem-rgb), .04); border-bottom: 2px solid var(--gem);
}
.gem-header .label {
font: 800 13px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
text-transform: uppercase; letter-spacing: .08em; color: #fff;
}
.gem-tag {
display: inline-block; padding: 2px 8px;
font: 10px/1 ui-monospace, monospace;
letter-spacing: .08em; text-transform: uppercase;
color: var(--gem); background: rgba(var(--gem-rgb), .08);
border: 1px solid var(--gem); border-radius: 999px;
}
.gem-index { margin-left: auto; font: 10px/1 ui-monospace, monospace; color: #64748b; letter-spacing: .1em; }
.gem-slot { position: relative; width: 100%; aspect-ratio: 16 / 9; }
/* Mobile: shrink chrome (showcase padding + frame width) so the gem frame
still appears but the map area gets more of the viewport. The slot also
sizes to viewport height so the map is actually usable on a phone. */
@media (max-width: 768px) {
#showcase { padding: 8px; gap: 12px; }
.gem-frame { --fw: 8px; }
.gem-frame-content { border-width: 1px; }
.gem-header { padding: 6px 10px; }
.gem-header .label { font-size: 11px; }
.gem-tag { font-size: 9px; padding: 1px 6px; }
.gem-index { font-size: 9px; }
.gem-slot {
aspect-ratio: auto;
height: calc(100vh - 120px); /* viewport minus showcase padding + gem header */
min-height: 480px;
}
}
/* Very small screens (≤420px wide): even tighter chrome. */
@media (max-width: 420px) {
#showcase { padding: 4px; gap: 8px; }
.gem-frame { --fw: 6px; }
.gem-slot { height: calc(100vh - 100px); min-height: 420px; }
}
doxa-country-map, country-map { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
doxa-country-map:fullscreen, doxa-country-map:-webkit-full-screen,
country-map:fullscreen, country-map:-webkit-full-screen { position: fixed; inset: 0; width: 100vw; height: 100vh; }
/* ── Overview mode: grid every slot + square aspect to see all at once ── */
body.zoom-out-all #showcase {
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px;
}
body.zoom-out-all .gem-slot { aspect-ratio: 1 / 1; }
body.zoom-out-all .gem-frame { --fw: 10px; }
body.zoom-out-all .gem-header { padding: 5px 8px; }
body.zoom-out-all .gem-header .label { font-size: 10px; letter-spacing: .06em; }
body.zoom-out-all .gem-tag { font-size: 9px; padding: 1px 6px; }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════════
⚡ EDIT HERE — one flat array. Add / remove / reorder slots freely.
─────────────────────────────────────────────────────────────────
Each entry becomes one gem-framed <doxa-country-map>. The profile-tabs bar
at the top auto-groups by the `profile` field — add a new profile
just by using a new value.
profile application profile name (must match
plugin-designs/country-map/<name>.vue)
label header title shown on the gem-frame
gem amethyst | sapphire | aquamarine | emerald |
topaz | citrine | tourmaline. Omit to auto-cycle.
config profile-config JSON passed to <doxa-country-map>. Optional.
`profile` + `tk` auto-injected, you don't need them here.
═══════════════════════════════════════════════════════════════════ -->
<script>
// Pulled from .env at dev-server start. Vite replaces %VITE_MAPBOX_TOKEN%
// with the value at request time; the .env file is gitignored.
window.DOXA_TOKEN = '%VITE_MAPBOX_TOKEN%';
window.DOXA_SLOTS = [
// ── country-map: same profile, three different configs ──
{ profile: 'country-map', label: 'Engagement', gem: 'emerald',
config: { dataSource: 'pray-tools', instanceId: 'dev-engagement',
tabs: [{ id: 'engagement', colorStrategy: 'engagement', legend: 'engagement', popup: 'engagement' }] } },
{ profile: 'country-map', label: 'Prayer', gem: 'sapphire',
config: { dataSource: 'pray-tools', instanceId: 'dev-prayer',
tabs: [{ id: 'prayer', colorStrategy: 'prayer', legend: 'prayer', popup: 'prayer' }] } },
{ profile: 'country-map', label: 'Adoption', gem: 'topaz',
config: { dataSource: 'pray-tools', instanceId: 'dev-adoption',
tabs: [{ id: 'adoption', colorStrategy: 'adoption', legend: 'adoption', popup: 'adoption' }] } }
];
</script>
<!-- ═══ HERO ═══ -->
<div class="vite-hero">
<div class="vite-filestack">
<svg class="vite-file" viewBox="0 0 108 128" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path class="body" d="M8 4 L72 4 L104 36 L104 124 L8 124 Z" />
<path class="fold" d="M72 4 L72 36 L104 36 Z" />
<text x="54" y="112" text-anchor="middle">.iife.js</text>
</svg>
<img src="https://vitejs.dev/logo.svg" alt="Vite" />
</div>
<div class="caption">one <strong>Vite bundle</strong> → many <strong>&lt;doxa-country-map&gt;</strong> instances</div>
<div class="vite-beams">
<svg viewBox="0 0 520 60" preserveAspectRatio="none">
<defs>
<linearGradient id="beamGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#41d1ff" />
<stop offset="55%" stop-color="#b9a6f5" />
<stop offset="100%" stop-color="#ffc91a" />
</linearGradient>
</defs>
<line x1="260" y1="0" x2="20" y2="60" />
<line x1="260" y1="0" x2="80" y2="60" />
<line x1="260" y1="0" x2="140" y2="60" />
<line x1="260" y1="0" x2="200" y2="60" />
<line x1="260" y1="0" x2="260" y2="60" />
<line x1="260" y1="0" x2="320" y2="60" />
<line x1="260" y1="0" x2="380" y2="60" />
<line x1="260" y1="0" x2="440" y2="60" />
<line x1="260" y1="0" x2="500" y2="60" />
</svg>
</div>
</div>
<!-- ═══ TOPBAR — profile tabs (auto-built from DOXA_SLOTS) + Overview ═══ -->
<header class="topbar" id="topbar">
<div class="spacer"></div>
<button class="overview-btn" id="zoomOutBtn" onclick="toggleZoomOut()"
title="Zoom out so every slot is visible at once — each slot's unique gem color proves isolation">Overview</button>
</header>
<!-- ═══ SHOWCASE ═══ -->
<div id="showcase"></div>
<script>
// ── Gem palette ──
const GEM_PALETTE = {
amethyst: { name: 'amethyst', hex: '#9333ea' },
sapphire: { name: 'sapphire', hex: '#2563eb' },
aquamarine: { name: 'aquamarine', hex: '#0891b2' },
emerald: { name: 'emerald', hex: '#059669' },
topaz: { name: 'topaz', hex: '#d97706' },
citrine: { name: 'citrine', hex: '#ca8a04' },
tourmaline: { name: 'tourmaline', hex: '#db2777' },
onyx: { name: 'onyx', hex: '#1f2937' }
};
const GEM_CYCLE = ['amethyst', 'sapphire', 'aquamarine', 'emerald', 'topaz', 'citrine', 'tourmaline'];
function gemForIndex(i) { return GEM_PALETTE[GEM_CYCLE[((i % GEM_CYCLE.length) + GEM_CYCLE.length) % GEM_CYCLE.length]]; }
function gemByName(n) { return GEM_PALETTE[n] || GEM_PALETTE.onyx; }
function hexToRgb(hex) {
const h = hex.replace('#', '');
const f = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
return parseInt(f.slice(0, 2), 16) + ', ' + parseInt(f.slice(2, 4), 16) + ', ' + parseInt(f.slice(4, 6), 16);
}
// ── State ──
const TK = window.DOXA_TOKEN || '';
// The parent staging index passes a language via ?locale=<code> on this page's
// URL (set by its top-level language selector). We forward it to every map as
// the `locale` prop (profile-config.locale) — the switch lives in staging, not
// on the map. Empty/absent → each map keeps its i18n-detected default.
const LOCALE = new URLSearchParams(location.search).get('locale') || '';
const SLOTS = window.DOXA_SLOTS || [];
const topbar = document.getElementById('topbar');
const showcase = document.getElementById('showcase');
const STORAGE_KEY = 'doxa-country-maps:country-map:profile';
// Auto-derive unique profile names (preserving first-appearance order)
const PROFILES = [];
SLOTS.forEach(s => { if (!PROFILES.includes(s.profile)) PROFILES.push(s.profile); });
// Initial active profile — saved choice if still present, else the first one
const saved = localStorage.getItem(STORAGE_KEY);
let activeProfile = PROFILES.includes(saved) ? saved : PROFILES[0];
// Build the profile tab bar
function buildTabs() {
Array.from(topbar.querySelectorAll('.profile-tab')).forEach(el => el.remove());
const spacer = topbar.querySelector('.spacer');
PROFILES.forEach(p => {
const btn = document.createElement('button');
btn.className = 'profile-tab' + (p === activeProfile ? ' active' : '');
btn.textContent = p;
btn.onclick = () => selectProfile(p);
topbar.insertBefore(btn, spacer);
});
}
// ── Render the slots for the active profile ──
function renderActive() {
showcase.innerHTML = '';
const slotsForProfile = SLOTS.filter(s => s.profile === activeProfile);
slotsForProfile.forEach((slot, i) => {
const gem = slot.gem ? gemByName(slot.gem) : gemForIndex(i);
const gemRgb = hexToRgb(gem.hex);
const config = Object.assign({}, slot.config, { profile: slot.profile, tk: TK }, LOCALE ? { locale: LOCALE } : {});
const el = document.createElement('div');
el.className = 'gem-frame';
el.id = 'frame-' + (slot.config?.instanceId || `${slot.profile}-${i}`);
el.style.setProperty('--gem', gem.hex);
el.style.setProperty('--gem-rgb', gemRgb);
el.innerHTML = `
<svg class="gem-bevel-svg" xmlns="http://www.w3.org/2000/svg">
<line/><line/><line/><line/>
</svg>
<div class="gem-frame-content">
<div class="gem-header">
<span class="label">${slot.label}</span>
<span class="gem-tag">${gem.name}</span>
<span class="gem-index">#${i + 1}</span>
</div>
<div class="gem-slot">
<doxa-country-map profile-config='${JSON.stringify(config)}'></doxa-country-map>
</div>
</div>`;
showcase.appendChild(el);
});
requestAnimationFrame(updateBevels);
}
// ── Corner bevels (mitre-joint lines) ──
function updateBevels() {
document.querySelectorAll('.gem-frame').forEach(frame => {
const svg = frame.querySelector('.gem-bevel-svg');
if (!svg) return;
const w = frame.offsetWidth, h = frame.offsetHeight;
if (!w || !h) return;
const fw = parseInt(getComputedStyle(frame).paddingTop) || 14;
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.setAttribute('width', w);
svg.setAttribute('height', h);
const ls = svg.querySelectorAll('line');
if (ls.length < 4) return;
ls[0].setAttribute('x1', 0); ls[0].setAttribute('y1', 0); ls[0].setAttribute('x2', fw); ls[0].setAttribute('y2', fw);
ls[1].setAttribute('x1', w); ls[1].setAttribute('y1', 0); ls[1].setAttribute('x2', w - fw); ls[1].setAttribute('y2', fw);
ls[2].setAttribute('x1', 0); ls[2].setAttribute('y1', h); ls[2].setAttribute('x2', fw); ls[2].setAttribute('y2', h - fw);
ls[3].setAttribute('x1', w); ls[3].setAttribute('y1', h); ls[3].setAttribute('x2', w - fw); ls[3].setAttribute('y2', h - fw);
});
}
new ResizeObserver(updateBevels).observe(showcase);
window.addEventListener('resize', updateBevels);
// ── Actions ──
function selectProfile(p) {
if (p === activeProfile) return;
activeProfile = p;
localStorage.setItem(STORAGE_KEY, p);
buildTabs();
renderActive();
}
function toggleZoomOut() {
const on = document.body.classList.toggle('zoom-out-all');
document.getElementById('zoomOutBtn').classList.toggle('active', on);
requestAnimationFrame(updateBevels);
setTimeout(() => window.dispatchEvent(new Event('resize')), 350);
}
// ── Boot ──
buildTabs();
renderActive();
</script>
<script>window.MAP_APP_BASE_URL = '/';</script>
<!-- Vite multi-page entry. In dev, served transformed; in build, Vite emits
../../app/country-map.iife.js and rewrites this script tag. -->
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,31 @@
/**
* plugin-designs/country-map/index.js
*
* Bundle entry — registers <doxa-country-map>.
*
* Usage from a host page (a DX-life country page is the first consumer):
* <script src="/dx-maps/country-map/country-map.js"></script>
* <doxa-country-map profile-config='{"profile":"country-map","countryCodes":["TCD"],"colorByPrayer":true}'></doxa-country-map>
*
* ONE tag, unlike doxa-simple-map's two: there is no legacy name to keep working, because nothing
* has ever embedded this map on its own. Adding a second alias now would create the back-compat
* problem rather than solve one.
*/
import { defineMapElement } from '@map/elements/defineMapElement.js'
import { registerStrategies } from '@map/colors/_registry.js'
// Evaluated by Vite relative to THIS file, so it captures only this bundle's profiles, and handed
// to ProfileLoader via app.provide('profileModules') — the Q3 contract, same as every bundle.
const profileModules = import.meta.glob('./*.vue')
// Bundle-private colour strategies. Empty today: this bundle draws with the shared region palette
// and the prayer strategy the library already registers.
registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))
const DoxaCountryMapElement = defineMapElement({ profileModules, configureApp: () => {} })
if (typeof customElements !== 'undefined' && !customElements.get('doxa-country-map')) {
customElements.define('doxa-country-map', DoxaCountryMapElement)
}
export default DoxaCountryMapElement

View file

@ -38,7 +38,7 @@ import UupgsList from './src/UupgsList.vue'
// Their strings, via their own English keys (see src/useRegionsI18n.js). The goal sentences,
// the stat labels and the table headers are all t() calls on their page; hardcoding the
// English was the last thing keeping this clone monolingual.
import { useRegionsI18n } from './src/useRegionsI18n.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
import { splitLastWord } from './src/useLastWordHighlight.js'
// Region colours come from the shared strategy (check:one-palette), and REGION_ORDER is the
// order its CVD separation was validated in — the legend must render in it for that to hold.
@ -46,7 +46,7 @@ import { PALETTE as REGION_PALETTE, WAGF_REGIONS, REGION_ORDER } from '@map/colo
// DX-life's spacing/type scales under THEIR token names. Prepended to this
// component's shadow CSS so every var() below resolves inside the shadow root.
import { DXLIFE_TOKENS } from './dxlife-tokens.js'
import RegionCountryMap from './src/RegionCountryMap.vue'
import RegionCountryMap from '@map/components/maps/RegionCountryMap.vue'
// REGLIB batch 2: the stat card, the three goal cards and their bars are library components now.
// They carry their own shadow CSS (useShadowStyles) and the card surface they share, so the rules
// for .rd-card / .rd-stat* / .rd-goal* / .rd-big* / .rd-bar* / .rd-legend are no longer below.
@ -56,6 +56,7 @@ import ProgressBar from '@map/components/charts/ProgressBar.vue'
import BackToTopButton from '@map/components/BackToTopButton.vue'
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
import { summarizeRegions, emptyStats, toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
import { peopleGroupPoints, PEOPLE_GROUP_POINT_FIELDS } from '@map/utils/peopleGroupPoints.js'
const props = defineProps({
profileConfig: { type: String, default: '' },
@ -195,7 +196,7 @@ const sel = ref({
// that is what we had cloned. The two are different prose: location_description says where a
// people live, imb_people_description describes the people. So our popup was showing the wrong
// paragraph — visible content, not a field-name detail.
const FIELDS = 'name,slug,rop1,religion,imb_people_description,image_url,has_photo,' + PEOPLE_GROUP_LIST_FIELDS
const FIELDS = PEOPLE_GROUP_POINT_FIELDS + ',' + PEOPLE_GROUP_LIST_FIELDS
function formatNumber(n) {
// THEIRS, RegionDetail.vue:16: `n.toLocaleString(`${locale.value}-u-nu-latn`)` — locale
@ -652,21 +653,9 @@ async function loadRegions() {
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], injectedCfg)
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 || '',
// the wagf_region VALUE (e.g. 'latin_america_&_caribbean'), which is what
// library/colors/wagf-region.js keys its palette on
region: p.wagf_region?.value || '',
lng: parseFloat(p.longitude), lat: parseFloat(p.latitude),
committed: toNumber(p.people_committed), population: toNumber(p.population),
// Their popup's fields, mapped exactly as CountryMap.vue:107-115 maps them — including the
// has_photo GATE on the image, which is theirs: a row can carry an image_url that is a
// placeholder, and they only show it when the flag says there is a real photo.
rop1: p.rop1?.label || '', religion: p.religion?.label || '',
description: p.imb_people_description ?? '',
imageUrl: (p.has_photo && p.image_url) ? p.image_url : '',
})).filter((p) => Number.isFinite(p.lng) && Number.isFinite(p.lat))
// The mapping moved to @map/utils/peopleGroupPoints.js when country-map became its second
// consumer — same fields, same has_photo gate, same drop of rows without finite coordinates.
points.value = peopleGroupPoints(posts, toNumber)
} catch (error) {
console.error('[region-dashboards] fetch error:', error)
loadFailed.value = true

View file

@ -43,7 +43,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
import { splitLastWord } from './src/useLastWordHighlight.js'
import { useRegionsI18n } from './src/useRegionsI18n.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
// document.documentElement.dir, which belongs to the host page.

View file

@ -42,7 +42,7 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
// missing because DX-life translates 8 of our 11 locales — so English-only keys would push
// them to 97 and fail the gate. The moment someone adds these three strings to all locales,
// the strip localises with no code change here.
import { useRegionsI18n } from './src/useRegionsI18n.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
const { t } = useRegionsI18n()
const cfg = inject('profileConfig', ref(null))

View file

@ -39,7 +39,7 @@ import FilterDropdown from '@map/components/search/FilterDropdown.vue'
// several I had written from the markup instead: the rop1 filter is 'People Group' not
// 'Affinity Bloc', the search placeholder is the full
// 'Search names, country/continent, religions...', and prayer_coverage reads 'Intercessors'.
import { useRegionsI18n } from './useRegionsI18n.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
// THEIR SEARCH, via the repo's Fuse-compatible shim. fuzzy.js exposes the surface their UupgsList
// uses — keys, threshold, minMatchCharLength, matches[].indices — and its own header says those
// indices exist to drive the <span class="search-highlight"> wrapping. So the wiring is a port.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>country-map · country-map</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. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, doxa-country-map { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-country-map> custom element. -->
<script src="../country-map.js" integrity="sha384-Z1HvnNWZ0TwqOb8tqsXn1g1U24HwwDNsi1aLoC0DeyWCu5dxpC8xCxJ4+0LVRldP" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"country-map","dataSource":"pray-tools","instanceId":"country-map-country-map","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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("doxa-country-map");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins.
// 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token
// endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The
// element decides the same way from the token this page hands it, so the two cannot disagree.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 24,
"profileCount": 36,
"bundleCount": 25,
"profileCount": 37,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -165,6 +165,34 @@
}
]
},
{
"bundle": "country-map",
"scriptUrl": "./country-map/country-map.js",
"output": "dx-maps/country-map/country-map.js",
"integrity": "sha384-Z1HvnNWZ0TwqOb8tqsXn1g1U24HwwDNsi1aLoC0DeyWCu5dxpC8xCxJ4+0LVRldP",
"elements": [
"doxa-country-map"
],
"stage": "production",
"profiles": [
{
"profile": "country-map",
"stage": "production",
"label": "Country Map",
"file": "country-map.vue",
"description": "",
"element": "doxa-country-map",
"type": "single",
"isMap": false,
"iframeUrl": "./country-map/country-map/index.html",
"scriptUrl": "./country-map/country-map.js",
"embed": {
"iframe": "<iframe src=\"./country-map/country-map/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=\"./country-map/country-map.js\" integrity=\"sha384-Z1HvnNWZ0TwqOb8tqsXn1g1U24HwwDNsi1aLoC0DeyWCu5dxpC8xCxJ4+0LVRldP\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>"
}
}
]
},
{
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
@ -1080,7 +1108,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc",
"integrity": "sha384-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1",
"elements": [
"regions-dashboard"
],
@ -1098,12 +1126,12 @@
"description": "The dashboard half of the 2-tab Regions element: given a region (or a",
"element": "regions-dashboard",
"type": "single",
"isMap": true,
"isMap": false,
"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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"provider\":\"maplibre\",\"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-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1119,7 +1147,7 @@
"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-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"provider\":\"maplibre\",\"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-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1130,12 +1158,12 @@
"description": "The composite: ONE element carrying the SELECT VIEW switcher",
"element": "regions-dashboard",
"type": "nested-elements-mfe",
"isMap": true,
"isMap": false,
"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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"provider\":\"maplibre\",\"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-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 24,
"profileCount": 36,
"bundleCount": 25,
"profileCount": 37,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -165,6 +165,34 @@
}
]
},
{
"bundle": "country-map",
"scriptUrl": "./country-map/country-map.js",
"output": "dx-maps/country-map/country-map.js",
"integrity": "sha384-Z1HvnNWZ0TwqOb8tqsXn1g1U24HwwDNsi1aLoC0DeyWCu5dxpC8xCxJ4+0LVRldP",
"elements": [
"doxa-country-map"
],
"stage": "production",
"profiles": [
{
"profile": "country-map",
"stage": "production",
"label": "Country Map",
"file": "country-map.vue",
"description": "",
"element": "doxa-country-map",
"type": "single",
"isMap": false,
"iframeUrl": "./country-map/country-map/index.html",
"scriptUrl": "./country-map/country-map.js",
"embed": {
"iframe": "<iframe src=\"./country-map/country-map/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=\"./country-map/country-map.js\" integrity=\"sha384-Z1HvnNWZ0TwqOb8tqsXn1g1U24HwwDNsi1aLoC0DeyWCu5dxpC8xCxJ4+0LVRldP\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>"
}
}
]
},
{
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
@ -1080,7 +1108,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc",
"integrity": "sha384-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1",
"elements": [
"regions-dashboard"
],
@ -1098,12 +1126,12 @@
"description": "The dashboard half of the 2-tab Regions element: given a region (or a",
"element": "regions-dashboard",
"type": "single",
"isMap": true,
"isMap": false,
"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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"provider\":\"maplibre\",\"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-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1119,7 +1147,7 @@
"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-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"provider\":\"maplibre\",\"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-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1130,12 +1158,12 @@
"description": "The composite: ONE element carrying the SELECT VIEW switcher",
"element": "regions-dashboard",
"type": "nested-elements-mfe",
"isMap": true,
"isMap": false,
"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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"provider\":\"maplibre\",\"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-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -11,14 +11,10 @@
<script>window.MAP_OWNS_PAGE = true;</script>
<title>region-dashboards · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<!-- 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. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -32,11 +28,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading token…</div>
<div id="status">loading…</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-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -56,7 +52,7 @@
// 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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = false;
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<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-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -11,14 +11,10 @@
<script>window.MAP_OWNS_PAGE = true;</script>
<title>regions-mfe · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<!-- 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. -->
<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
@ -43,11 +39,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading token…</div>
<div id="status">loading…</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-ruxUgkhdb2OQPgfpdNgWuXmHE5UaMs6GzZt+URCPg7SjqSap+pCCsuCqOGNFfqJc" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-mArl7RMgEhLBFKNvyrPgLOw4lE/XWNbvPsGExPDuIkHTqBAC9VW8Ikh02lRZYCN1" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -67,7 +63,7 @@
// 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.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = false;
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.