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:
parent
6956fd790f
commit
2225e2b4bc
19 changed files with 1153 additions and 222 deletions
|
|
@ -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'
|
||||
|
||||
|
||||
37
library/utils/peopleGroupPoints.js
Normal file
37
library/utils/peopleGroupPoints.js
Normal 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))
|
||||
}
|
||||
5
plugin-designs/country-map/country-map.meta.json
Normal file
5
plugin-designs/country-map/country-map.meta.json
Normal 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."
|
||||
}
|
||||
125
plugin-designs/country-map/country-map.vue
Normal file
125
plugin-designs/country-map/country-map.vue
Normal 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>
|
||||
389
plugin-designs/country-map/index.html
Normal file
389
plugin-designs/country-map/index.html
Normal 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><doxa-country-map></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>
|
||||
31
plugin-designs/country-map/index.js
Normal file
31
plugin-designs/country-map/index.js
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
93
public/dx-maps/country-map/country-map.js
Normal file
93
public/dx-maps/country-map/country-map.js
Normal file
File diff suppressed because one or more lines are too long
214
public/dx-maps/country-map/country-map/index.html
Normal file
214
public/dx-maps/country-map/country-map/index.html
Normal 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>
|
||||
|
|
@ -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>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue