Update from source 932bf553
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-26 07:35:08 +00:00
parent 1d27e792b6
commit 28e74f9787
18 changed files with 478 additions and 328 deletions

View file

@ -14,7 +14,7 @@ capabilities, three outputs, one shared library, one build.
| | |
|---|---|
| 🗺️ **Two map engines** | Every map draws with **Mapbox** or **MapLibre** (free, no key) — switch with `"provider"` in its settings or `?provider=maplibre` in the address. Mapbox is the default. |
| 🗺️ **Two map engines** | Every map draws with **Mapbox** or **MapLibre** (free, no key) — switch with `"provider"` in its settings or `?provider=maplibre` in the address. By default it uses Mapbox when a token is available, MapLibre otherwise. |
| 🔑 **A key or a key service** | Give Mapbox a public token, or the address of a token service that hands one out — set once in the Plugin Studio header. |
| 🌍 **11 languages** | `?lang=` or `"locale"`, with Arabic right to left — [Languages](#languages). |
| 🧩 **Web components** | Each map is its own HTML tag, like `<all-people-groups>`, that works on any page — no framework needed. |

View file

@ -40,8 +40,39 @@ if (!wanted.length) {
process.exit(2)
}
/**
* ⚠ A DYNAMIC FETCH RESOLVES TO A DIRECTORY, AND A DIRECTORY ALWAYS EXISTS.
* The province layer fetches `${assetsBase}/boundaries/${PROVINCE_FILE[iso3]}`, so the parse
* above captures the literal prefix "boundaries/" and stops at the interpolation. That path is
* a real directory of 4,096 bytes, so it passed — proving nothing about the two files the map
* actually asks for. A check that cannot fail is the thing this repo keeps rediscovering.
* So a directory is never accepted as an asset, and any literal asset FILENAME mentioned in the
* leaf is checked on its own. That covers the interpolated case without trying to evaluate it.
*/
const dirLike = wanted.filter((r) => r.endsWith('/'))
const named = [...new Set(
[...src.matchAll(/['"`]([A-Za-z0-9_\-]+\.(?:geojson|json))['"`]/g)].map((m) => m[1]),
)]
/* ⚠ AND THE EXPANSION MUST NOT CONSULT THE FILESYSTEM TO DECIDE WHAT TO EXPECT.
* The first version only added a name if the file already existed, so deleting one simply
* removed it from the list and the gate went green on nine assets instead of ten. Proven by
* unstaging gb-CHN-ADM1.geojson: PASS, exit 0. The expectation has to come from the SOURCE —
* the filenames the leaf names — so that a file the leaf asks for and the tree lacks is a
* failure rather than a shorter list. */
for (const prefix of dirLike) {
const ext = /boundaries\//.test(prefix) ? '.geojson' : '.json'
for (const file of named.filter((f) => f.endsWith(ext))) wanted.push(prefix + file)
}
const missing = []
for (const rel of wanted) {
if (rel.endsWith('/')) {
/* Counted, not silently skipped: if the expansion above found nothing for this prefix the
reader must know the interpolated fetch went unverified. */
const found = wanted.filter((w) => w !== rel && w.startsWith(rel))
if (!found.length) missing.push(`${rel} — a dynamic fetch this check could not resolve to a file`)
continue
}
const p = resolve(POOL, rel)
if (!existsSync(p)) { missing.push(`${rel} — not staged`); continue }
const bytes = statSync(p).size

View file

@ -964,12 +964,16 @@ function openCountry(iso) {
if (!iso || !byIso.value[iso]) return
selected.value = byIso.value[iso]
selectedGroup.value = null
selectedProvince.value = null
shown.value = PAGE
ensurePeopleGroups()
/* Opening India or China IS the intent signal. 1.6 MB of Indian geometry is not something to
spend on the 173 countries nobody is looking at. */
ensureProvinces(iso)
}
function openGroup(g) { selectedGroup.value = g }
function closeSheet() { selected.value = null; selectedGroup.value = null }
function closeSheet() { selected.value = null; selectedGroup.value = null; selectedProvince.value = null }
async function load() {
const [data, adm0, pinData, doxaData] = await Promise.all([
@ -1087,6 +1091,108 @@ function paint(map, adm0) {
map.on('mouseleave', 'gr-fill', () => { map.getCanvas().style.cursor = '' })
}
/**
* ═══ PROVINCES, FETCHED ONLY WHEN SOMEONE LOOKS ══════════════════════════════════════════
*
* India and China hold 47.9% of the roster (855 + 154 of 2,107) and a national fill cannot
* answer "where would a team go" for half the map. But India's ADM1 geometry is 1,628 KB —
* nearly the size of the whole world ADM0 file — on a leaf that already loads about 4 MB and
* is meant to be embedded on thousands of partner sites. Loading it for the 173 countries
* nobody is looking at would be a tax on every reader for the benefit of two.
*
* So it arrives on intent: opening India or China fetches that country's provinces once, and
* nothing else changes for anyone else. China is 54 KB, India 1,628 KB, and both are cached
* after the first open.
*
* COLOURED BY THE SAME LADDER as the country fill, because a reader who has just learned what
* the clay ramp means must not have to learn a second scale one zoom later.
*/
const provinceState = ref({}) // iso3 -> 'loading' | 'ready' | 'failed'
const provinceGeo = ref({}) // iso3 -> the loaded FeatureCollection, kept for recolouring
/* A province is coloured by the SAME rule as a country, mode included — a reader who has just
learned the clay ramp must not meet a second scale one zoom later. featureColor cannot be
reused directly because it looks a country up by iso3; this is the same two lines against a
province's own counts. */
const colourProvinces = (geo) => {
for (const f of geo.features) {
const n = mode.value === 'gap' ? f.properties.zero : f.properties.groups - f.properties.zero
const b = f.properties.groups > 0 ? bandFor(mode.value === 'gap' ? BANDS : COVER_BANDS, n) : null
f.properties.pcolor = b ? b.color : NO_DOXA_FILL
}
}
/* Mode changes after a province layer exists would otherwise leave it painted in the old
scale — the country beneath it would say one thing and the province another. */
const repaintProvinces = () => {
const map = mapRef.value
if (!map) return
for (const [iso3, geo] of Object.entries(provinceGeo.value)) {
colourProvinces(geo)
const src = map.getSource(`gr-prov-${iso3}`)
if (src) src.setData(geo)
}
}
const provinceRows = ref(null) // the measured table: province -> counts
const selectedProvince = ref(null)
async function ensureProvinceTable() {
if (provinceRows.value) return provinceRows.value
const r = await fetch(`${assetsBase.value}/tables/gospel-by-province.json`)
provinceRows.value = await r.json()
return provinceRows.value
}
const PROVINCE_FILE = { IND: 'gb-IND-ADM1.geojson', CHN: 'gb-CHN-ADM1.geojson' }
async function ensureProvinces(iso3) {
const map = mapRef.value
if (!map || !PROVINCE_FILE[iso3] || provinceState.value[iso3]) return
provinceState.value = { ...provinceState.value, [iso3]: 'loading' }
try {
const [geo, table] = await Promise.all([
fetch(`${assetsBase.value}/boundaries/${PROVINCE_FILE[iso3]}`).then((r) => r.json()),
ensureProvinceTable(),
])
/* The counts come from the measured table, never recomputed here — the point-in-polygon
that produced them belongs in the build, and a second implementation in the leaf is how
two surfaces come to disagree. */
const by = {}
for (const row of (table.rows || [])) if (row.iso3 === iso3) by[row.province] = row
for (const f of geo.features) {
const row = by[f.properties.shapeName]
f.properties.zero = row ? row.zero_access_groups : 0
f.properties.groups = row ? row.groups : 0
}
provinceGeo.value = { ...provinceGeo.value, [iso3]: geo }
colourProvinces(geo)
const src = `gr-prov-${iso3}`
if (!map.getSource(src)) {
map.addSource(src, { type: 'geojson', data: geo })
map.addLayer({
id: `${src}-fill`, type: 'fill', source: src,
paint: { 'fill-color': ['get', 'pcolor'], 'fill-opacity': ['case', ['>', ['get', 'groups'], 0], 0.82, 0] },
}, 'gr-line')
map.addLayer({
id: `${src}-line`, type: 'line', source: src,
paint: { 'line-color': 'rgba(255,255,255,0.75)', 'line-width': 0.7 },
}, 'gr-pins-halo')
map.on('click', `${src}-fill`, (e) => openProvince(iso3, e.features?.[0]?.properties?.shapeName))
map.on('mouseenter', `${src}-fill`, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', `${src}-fill`, () => { map.getCanvas().style.cursor = '' })
}
provinceState.value = { ...provinceState.value, [iso3]: 'ready' }
} catch (_) {
/* A province view is an enrichment; losing it must never take the country view with it. */
provinceState.value = { ...provinceState.value, [iso3]: 'failed' }
}
}
function openProvince(iso3, name) {
const t = provinceRows.value
if (!t || !name) return
selectedProvince.value = (t.rows || []).find((r) => r.iso3 === iso3 && r.province === name) || null
}
function closeProvince() { selectedProvince.value = null }
function pinFC() {
const feats = new Array(pinRows.value.length)
const hnrFor = (code) => {
@ -1367,6 +1473,7 @@ function setMode(m) {
if (mode.value === m) return
mode.value = m
if (mapRef.value && adm0Ref.value) paint(mapRef.value, adm0Ref.value)
repaintProvinces()
}
/* ═══ NUMBER FORMAT IS HELD FOR THE DRIVER ════════════════════════════════════════════════
@ -1883,7 +1990,34 @@ useShadowStyles(
<div v-if="err" class="gm-err">{{ err }}</div>
<!-- Tap a country: bottom sheet on mobile, corner card on desktop. -->
<div v-if="selected || showGapList" class="gm-sheet">
<!-- ═══ A PROVINCE, WHEN ONE IS TAPPED ═══════════════════════════════════════════════
Sits above the country sheet because it is the narrower answer to the same question,
and a reader who tapped a province asked about the province. Closing it returns to the
country rather than to the world, so the drill-down is reversible one step at a time.
English prose in the caveat line, per the standing decision. -->
<div v-if="selectedProvince" class="gm-sheet">
<div class="gm-sh-head">
<button type="button" class="gm-back" @click="closeProvince">‹ {{ selectedProvince.country }}</button>
<button type="button" class="gm-back" @click="closeSheet">{{ t('gospel.sheet.close') }}</button>
</div>
<h3 class="gm-sh-title">{{ selectedProvince.province }}</h3>
<div class="gm-stat"><span>{{ t('gospel.stat.peopleGroups') }}</span><b>{{ fmt(selectedProvince.groups) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.population') }}</span><b>{{ fmt(selectedProvince.people) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.legend.tier0') }}</span><b>{{ fmt(selectedProvince.zero_access_groups) }}</b></div>
<div class="gm-stat" v-if="selectedProvince.zero_access_people">
<span>{{ t('gospel.stat.peopleAffected') }}</span><b>{{ fmt(selectedProvince.zero_access_people) }}</b>
</div>
<p v-if="selectedProvince.zero_access_examples" class="gm-note" dir="ltr" style="margin:6px 0 0">
Largest with nothing: <b>{{ selectedProvince.zero_access_examples }}</b>
<template v-if="selectedProvince.ways_in"><br>Way in: {{ selectedProvince.ways_in }} — chosen for the country, not this province.</template>
</p>
<p class="gm-note" dir="ltr" style="margin:6px 0 0">
A group is placed in a province from its own coordinate, which marks where a people is
found rather than a settlement. Near a border that point can fall on the wrong side.
</p>
</div>
<div v-else-if="selected || showGapList" class="gm-sheet">
<!-- ITEM 0 — the ranked gap: the largest groups with nothing found anywhere. -->
<template v-if="showGapList && !selected">

View file

@ -358,7 +358,17 @@ useShadowStyles(
// commercial and BLOCKED ON THE DRIVER, so this button keeps our stand-in and the family remains
// the one measured difference here. Everything geometric now matches.
'.filters__toggle{ background:none; cursor:pointer; font:inherit; font-size:var(--font-size-sm);' +
' padding:var(--spacing-xs) var(--spacing-2xl); border:1px solid transparent; border-radius:100px;' +
// ★ LITERAL 10px, NOT var(--spacing-xs) -- I used the token and it DOES NOT EXIST.
// dxlife-tokens.js:43-45 says so explicitly and gives the reason: "--spacing-xs (10px) and
// --spacing-sm (13.3px) are deliberately NOT tokenised: '10px' is far too common a literal in this
// CSS to substitute safely, and a wrong substitution here is worse than a magic number because it
// reads as deliberate." I substituted anyway.
// WHAT AN UNDEFINED var() COSTS, measured by rega: it invalidates the WHOLE declaration at compute
// time, so padding fell to 0 on BOTH axes and the button went 25.44 -> 17.63 -- FURTHER from their
// 37.63 than before I touched it. The border, radius and line-height all landed; only this line
// failed, and it failed silently. Their computed value is "10px 41.6158px", so with the literal:
// 15.625 + 2x10 + 2x1 = 37.63 tall and ~210.7 wide (theirs 210.73).
' padding:10px var(--spacing-2xl); border:1px solid transparent; border-radius:100px;' +
' line-height:var(--line-height-none);' +
' display:inline-flex; align-items:center; gap:var(--spacing-2xs); margin-inline:auto; color:var(--dx-text); }' +
'.filters__toggle svg{ transition:transform 200ms ease; }' +

View file

@ -578,7 +578,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS",
"integrity": "sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI",
"elements": [
"gospel-resources-map"
],
@ -598,7 +598,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -612,7 +612,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -626,7 +626,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -640,7 +640,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -654,7 +654,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -887,7 +887,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ",
"integrity": "sha384-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd",
"elements": [
"regions-dashboard"
],
@ -907,7 +907,7 @@
"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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- 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-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -921,7 +921,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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -935,7 +935,7 @@
"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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- 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-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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 <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -578,7 +578,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS",
"integrity": "sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI",
"elements": [
"gospel-resources-map"
],
@ -598,7 +598,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -612,7 +612,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -626,7 +626,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -640,7 +640,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -654,7 +654,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-cZU1Cw8MbYFPD8keWQ8q+LgMCkXDihd9UvXZoEtrravoxAfPQenQuErMIYUGIYfS\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-oUChFeTRtQcf2dYiiovA3j3+zrvKjvsmPkMddc6pLnOAk0eUUzU8ItIZvMC/2OZI\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -887,7 +887,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ",
"integrity": "sha384-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd",
"elements": [
"regions-dashboard"
],
@ -907,7 +907,7 @@
"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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- 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-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -921,7 +921,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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -935,7 +935,7 @@
"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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- 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-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -36,7 +36,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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,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-p3OwJ38WP4LzT1Bu38EwBYic+bbo+3N8EMIG6DQcRHDe7E2JERlDreBRUMzOW7AZ" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-MJahBXMtSLaS+4a+KMIuI17Ca6fF5nkuri80WNZVofIGZAH8HC+rPPlcZVTCovbd" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -1,274 +0,0 @@
---
name: make-a-doxa-map
description: >-
Use this skill when a user (typically a non-coder "map maker" / prayer
mobilizer) asks you to build a new map/plugin with the doxa-maps bundler, add
another screen to a map, embed one on a site, or ship one as a mobile app.
Teaches the 5 plugin capabilities, the 5 deployment methods, the
plugin-designs/ root-Vue pattern, the graduation path, and the exact
copy-the-starter -> `bun run build` flow. Grounded in the REAL package.json
scripts (dev/build/preview/phone) and the REAL structure (plugin-designs/,
library/, internal/).
---
# Make a doxa-map (use the plugin bundler)
You are the AI agent for a **map maker** — usually someone who does NOT write
code. Your job is to build maps for them by driving this bundler. This skill is
your operating manual: what the bundler is, how to make a new plugin by copying
the starter, and how to ship it.
> **The one thing to remember:** a new plugin is **one folder** under
> `plugin-designs/`, and **`bun run build`** turns every folder into a
> paste-anywhere `.js` custom element. Everything else is detail.
Run every command from the bundler repo root
(`.../doxa-maps-plugin-studio/`, the folder that has `package.json` and
`plugin-designs/`).
---
## 0. Mental model (read this first)
The bundler is a **JAMstack plugin bundler**: one build step turns each map into
a self-contained web component ("plugin") that plugs in anywhere. Four words:
| Word | Plain meaning |
|---|---|
| **bundle / plugin** | One folder under `plugin-designs/` = one plugin. It compiles into a **single `.js` file** you can paste on any page. "Bundle" and "plugin" are the same thing. |
| **profile** | One `.vue` file inside the folder = one **screen** (one map). A bundle can hold one profile or many. The `.vue` filename (minus `.vue`) IS the profile's name. |
| **root Vue / root.vue** | Each profile is a normal Vue component — the "root" of that one screen. This is the **root-Vue pattern**: a folder with a root Vue file = a plugin. |
| **`src/`** | The plugin's **private sandbox** (its own colors, components, helpers). Nothing here can affect another plugin. |
Three zones — always know which one a file is in:
| Zone | What | Edit it? |
|---|---|---|
| `library/` (imported as `@map/…`) | SHARED, reusable, parameterized building blocks — the map, legend, controls, search bar, composables, color/API registries | **No — reuse only.** A change here can break every map. |
| `plugin-designs/<your-map>/` | YOUR plugin: root `.vue` profiles (flat) + a `src/` sandbox | **Yes — build anything.** Nothing here leaks. |
| `internal/` | the scanner/build machinery (clean, tree, gallery, guards) | **Never touch.** |
Rule of thumb: **local by default.** Code only moves into `library/` after a
*second* plugin genuinely needs it (see the Graduation Path below).
---
## 1. The 5 PLUGIN CAPABILITIES (how much the author nests)
Capability = how much composition the plugin does. It is NOT "complexity" and it
is NOT a quality ranking — it is a ladder of nesting. The studio gallery sorts
**5 → 1** (most-composed first). Pick the LOWEST tier that does the job.
| # | Capability | What it is | Live example in this repo |
|---|---|---|---|
| 1 | **Single-element plugin** | one folder → one JS bundle → one custom element. Simplest, lightest. | `plugin-designs/doxa-vision-card/` (a pure single element, no map, renders instantly) |
| 2 | **Prop-table plugin** | one root Vue defines a **prop table** + **element table** → ONE plugin renders many parameterized variants | `plugin-designs/doxa-simple-map/` (Prayer / Engagement / Adoption from one plugin, via `simple-map.instances.json`) |
| 3 | **Monolithic plugin** | one root Vue nests many maps/layouts *internally* → one element. Downside: you can't embed just one inner tab. | `plugin-designs/doxa-research-map/` (research map: many maps + layout in one element) |
| 4 | **Nested-element SPA** | each inner map is its OWN plugin-design; a **layout** nests them → one element, but each inner map is *also* embeddable on its own AND the whole SPA embeds | `plugin-designs/my-upg-100-list/`, `plugin-designs/research-dashboard/`, `plugin-designs/cities-internet/` |
| 5 | **Nested-SPA / multi-SPA** | nest multiple SPAs into one SPA → a whole modular platform | the doxa-maps studio / mobile app: `internal/generate-gallery.mjs` emits `public/index.html` as a Vue nested-SPA (vertical tabs, each tab a plugin of a different tier) |
**How to choose:** one screen, no data variants → cap 1. One screen, several
preset variants → cap 2 (add an `*.instances.json`). Several screens fused into
one you never split → cap 3. Several screens that must *also* be individually
embeddable → cap 4. A whole platform of SPAs → cap 5 (the studio already IS this;
you rarely author a new cap-5 — you add cap 1–4 plugins and the studio composes
them for you).
---
## 2. The 5 DEPLOYMENT METHODS (where the SAME plugin runs)
Author once; the SAME built `.js` (or its standalone page) runs all of these.
Methods 1–3 are **proven**; 4–5 are **deferred POCs** (no build script on this
branch yet — do not promise a one-command WordPress/desktop build).
| # | Method | How | Status | Command |
|---|---|---|---|---|
| 1 | **iframe** on any/partner site | paste the `embed.iframe` snippet from `manifest.json` | ✅ proven | `bun run build` → host `public/doxa-maps/` |
| 2 | **Mobile app** (Capacitor, Android) | build wraps the studio SPA into an APK; no new code | ✅ proven | `bun run phone` |
| 3 | **Direct-JS** (`<script src>` + custom-element tag, SRI-hashed, props/events, Shadow DOM) | paste the `embed.script` snippet from `manifest.json` | ✅ proven | `bun run build`, then paste snippet |
| 4 | **WordPress plugin** | same iframe or `<script>` snippet in a WP **Custom HTML** block | ⚠️ deferred POC (no WP build script) | `bun run build`, paste snippet |
| 5 | **Rust desktop (Tauri)** | front-end → desktop shell | ⚠️ deferred POC (not scaffolded) | none exists yet |
Full command/install detail per method:
[`documentation/README-SECTION-3-cli-commands.md`](../../documentation/README-SECTION-3-cli-commands.md).
Every `bun run build` writes `public/doxa-maps/manifest.json` — the machine-
readable index of all bundles + profiles, each carrying ready-to-paste
`embed.iframe`, `embed.script` (with an SRI `integrity` hash), and raw
`iframeUrl` / `scriptUrl`. URLs are root-relative, so the whole `doxa-maps/`
folder is position-independent (drop it on Cloudflare/Vercel/`file://`).
---
## 3. Make a NEW plugin — the copy-the-starter flow (the main task)
The starter is **`plugin-designs/template-bundle/`** (it has its own README). It
is a tiny working bundle: two flat profiles that render a colored box (no Mapbox,
no data) plus a laid-out `src/` sandbox. **Copy it; never start from blank.**
### 3a. The 2-move happy path
```bash
# 1. copy the starter to your new plugin name (run from the repo root)
cp -r plugin-designs/template-bundle plugin-designs/my-plugin
# 2. build the one command
bun run build # → public/doxa-maps/my-plugin/my-plugin.js
```
The build auto-detects the new folder (it loops `plugin-designs/*/`), compiles it
to one IIFE `.js` custom element, runs the guards, and lists it in the studio.
### 3b. Rename exactly FOUR things so the copy has its own name
After `cp -r`, edit these four (nothing else) inside `plugin-designs/my-plugin/`:
1. **The folder name** — already done by `cp -r` (= bundle name = output
`my-plugin.js`).
2. **The tag in `index.js`** — `customElements.define('template-bundle', …)` →
`customElements.define('my-plugin', …)`. (There are two spots: the `.define`
call and the `!customElements.get('template-bundle')` guard.)
3. **The tag + `profile` strings in `index.html`** — the staging page mounts the
element, so replace `<template-bundle profile-config='{"profile":"…"}'>` tags
with `<my-plugin profile-config='{"profile":"my-screen"}'>`, and the
`template-bundle { … }` CSS selector.
4. **The `.vue` filenames** — rename `template-map-a.vue` to your screen's name
(e.g. `my-screen.vue`); the filename becomes the profile name. Delete
`template-map-b.vue` if you only want one screen.
Then `bun run build` again.
> **The flat rule:** profile `.vue` files sit **directly** in the plugin folder —
> there is NO `profiles/` subfolder. The entry discovers them with a flat glob
> (`import.meta.glob('./*.vue')`); a `.vue` in a subfolder would NOT be found.
> The small pieces a profile is built from live in `src/`.
### 3c. Fill in the map
1. **List what the map needs** — data, colors, controls (legend, search, share,
fullscreen…).
2. **Reuse from the library** by reference — `import { useMapInstance } from '@map/composables/useMapInstance.js'`. The menu of what `@map/…` offers is [`documentation/reference.md`](../../documentation/reference.md). **Never copy library code into your folder.**
3. **Put your own code in `src/`** — screens, components, hooks, helpers (imported by relative path, e.g. `import colors from './src/colors/colors.json'`). There is no alias for your sandbox — that boundary keeps it private.
4. **Set colors** in `src/colors/colors.json` (plain hex). For color that varies by data, copy `src/colors/example-mode.js` (a strategy `.js`).
### 3d. Add extra preset variants (capability 2)
To ship one plugin as several named embeddable variants (like Prayer / Engagement
/ Adoption from `doxa-simple-map`), add a `<profile>.instances.json` next to the
`.vue`. The build emits a standalone page per instance at
`my-plugin/<instance>/index.html`. This is the cap-2 prop-table pattern.
### 3e. Embed the result
From `public/doxa-maps/manifest.json`, or hand-write from the starter's shape:
```html
<script src="/doxa-maps/my-plugin/my-plugin.js"></script>
<my-plugin profile-config='{"profile":"my-screen"}'></my-plugin>
```
`profile` must match a `.vue` filename (without `.vue`).
---
## 4. Iterate live (the dev server)
```bash
bun run dev # Vite dev server at http://localhost:5173
```
- The plugin's staging page is
`http://localhost:5173/plugin-designs/my-plugin/index.html`.
- Edits hot-reload — this is how you show the map maker changes in near-real-time
and take feedback.
- `bun run preview` serves the last production build; `bun run serve` serves
`public/` (the built tree + studio) at `http://localhost:4173`.
---
## 5. The graduation path (why the root-Vue pattern matters)
A plugin starts **non-reusable** — its own components live in its `src/`.
**Graduation** = deliberately extracting a component into the shared `library/`
**only after** it is proven reusable AND parameterized.
- **Do NOT graduate prematurely.** Pushing non-parameterized code into `library/`
poisons every other plugin with bad examples / breakage. Local by default;
promote later — an expert move, not a first move.
- The bundler is a **refactor-cruncher**: take an open-source map app → make it a
plugin (a folder + root Vue) → make its components reusable/parameterized → now
it runs in all 5 deploy targets and every other plugin can share the pieces.
Practical rule for you as the agent: keep new work in the plugin's `src/`. Only
suggest moving something to `library/` when a *second* plugin needs the exact same
thing and it takes props (no hard-coded specifics).
---
## 6. The plugin-studio (the auto-generated gallery)
On build, `bun run gallery` (the last build step) writes `public/index.html` — a
**Category-5 Vue nested-SPA** that auto-detects every plugin and lists them sorted
**capability 5 → 1**. It's called a "studio" (not "gallery") because it's the
development location where all tiers sit in one place. You don't hand-edit it; it
regenerates from `manifest.json` every build. Your new plugin appears in it
automatically.
The same `public/index.html` is the Capacitor mobile-app launch entry
(`capacitor.config.json` `webDir: public`), so restoring/building the studio SPA
is also the deploy-method-2 mobile app.
---
## 7. Verify your work (what "done" looks like)
1. `bun run build` prints **ALL GUARDS PASS** (network-policy, iso2-map,
resource-join, tables-complete, bundles-shipped, map-paint) and ends with the
gallery writing `public/index.html`. If a guard fails, read its message — it
names the exact file/rule.
2. `public/doxa-maps/my-plugin/my-plugin.js` exists (the IIFE bundle) and
`public/doxa-maps/manifest.json` lists `my-plugin` with your profile(s) + embed
snippets.
3. `bun run dev` → the plugin renders at its staging URL and in the studio.
> **Concurrency note:** if a build fails mid-run with files vanishing (e.g. a
> `clean` wiped `public/doxa-maps/`), another process ran `bun run clean` on the
> same folder. Re-run `bun run build` cleanly (serialize it) — that is not a code
> bug.
---
## 8. Command cheat-sheet (all real, from `package.json`)
| Command | What it does |
|---|---|
| `bun install` | install deps into `node_modules/` (once; `npm install` also works) |
| `bun run dev` | Vite dev server at `:5173` (live plugin staging) |
| `bun run build` | clean → write token → build every `plugin-designs/*/` → tree → guards → studio. **The one command.** |
| `bun run preview` | serve the last production build |
| `bun run serve` | serve `public/` (built tree + studio) at `:4173` |
| `bun run phone` | build → Capacitor sync → assemble + install the Android APK (deploy method 2) |
| `BUNDLE=my-plugin bun run build:bundle` | build just one plugin (faster while iterating) |
---
## 9. Guardrails
- **Additive by default.** A new map = a NEW folder under `plugin-designs/`. Don't
edit other people's plugins or `internal/`.
- **Never edit `library/` to customize one map** — that's what `src/` is for.
- **Keep profile `.vue` files flat** in the plugin folder (no `profiles/` subdir).
- **Don't promise deploy 4/5 as one-command** — WordPress/Tauri are deferred POCs;
they reuse the method-1/3 artifacts.
- **Explain it in plain English** to the map maker — if you can't, they can't
follow. Simplicity is the goal.
## References (in this repo)
- `plugin-designs/template-bundle/README.md` — the starter's own README (copy-me detail)
- `plugin-designs/README.md` — the plugin-designs authoring guide (shared vs. yours, production vs. experimental)
- `documentation/README-SECTION-3-cli-commands.md` — CLI commands + installs per deploy method
- `documentation/reference.md` — the `@map/…` menu of reusable library pieces
- `documentation/tutorial.md` — 3 zones + 3 rules in one page
- `plugin-designs/doxa-simple-map/index.js` — a fuller, real-map reference entry

View file

@ -0,0 +1,249 @@
---
name: plugin-designer
description: >-
Use this skill to design and build a new map plugin in this repo's
plugin-designs/ folder, or to add a screen to an existing one. Teaches the
bundle = folder = one JS file pattern, reusing library/ before writing code,
the universal props every plugin must honour (map engine, language, Mapbox
token or token service), the rules for working under both MapLibre and
Mapbox, localization and right-to-left, the five capability levels, the one
build step, and the three outputs (web folder, mobile app, desktop app).
Works directly with the Plugin Studio (`bun run dev`).
---
# Plugin designer
You build map plugins for this repo. A **plugin** is a folder under `plugin-designs/`; one build
turns it into one JavaScript file that any page can embed as an HTML tag. Your plugin must work
the same under both map engines, in every language, and with or without a Mapbox token.
Run every command from the repo root (the folder with `package.json`).
---
## 1. The pattern: bundle = folder = one JS file
```
plugin-designs/<your-plugin>/ one FOLDER = one BUNDLE (the folder name is the bundle name)
├── index.js entry: registers the custom element, finds the profiles
├── index.html a staging page that mounts the element
├── <screen>.vue one .vue = one PROFILE (one screen), FLAT in the folder
├── <screen>.meta.json optional: {"type": "<capability>", "label": "…"}
├── <screen>.instances.json optional: named presets of one screen (capability 2)
├── data/ optional: the bundle's own data files (imported, so they ship inside the bundle)
└── src/ the bundle's PRIVATE code: components, colors, helpers
builds to ─────────────► public/dx-maps/<your-plugin>/<your-plugin>.js
```
- **Profiles are flat.** `index.js` finds them with `import.meta.glob('./*.vue')`; a `.vue` in a
subfolder is not found. The filename (without `.vue`) is the profile's name.
- **Elements live inside.** One bundle registers one tag named after the folder, and can hold more
elements for its own screens to nest (see `plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json`).
- **Folders starting with `_` are skipped** by the build: a free place for work in progress.
- **New plugins appear in the Plugin Studio at once, and in the Research Gallery's Experimental
section automatically.** Moving one into a production section is a deliberate edit to the
gallery's curated sections (`plugin-designs/doxa-maps-spa/doxa-maps-spa.vue`), not your call alone.
## 2. Start by copying the starter
```bash
cp -r plugin-designs/template-bundle plugin-designs/<your-plugin>
```
Then rename exactly these, and nothing else:
1. the tag in `index.js` — both the `customElements.define('template-bundle', …)` call and the
`customElements.get('template-bundle')` guard;
2. the tag, the `profile` strings and the CSS selector in `index.html`;
3. the `.vue` filenames (`template-map-a.vue` → `<screen>.vue`); delete `template-map-b.vue` if
you need one screen.
`template-map-a.vue` is a real minimal map: it draws `data/places.geojson` on a basemap with one
translated title, and already follows the page's engine, language (with right to left) and token,
falling back to keyless MapLibre. **Replace `data/places.geojson` with your own points** and you have
a working map to build on. Its title key (`template.title`) lives in the library catalogues. Your own
translated text needs its own catalogue file in `library/i18n/locales/`, which is a library change
(section 3). Until that lands, plain English in your `.vue` is fine.
Keep the entry's `configureApp` exactly as the starter has it: it installs Pinia, installs i18n
**and** provides it as `appI18n`, and provides `profileModules`. The element's root
(`library/ProfileLoader.vue`) cannot call `useI18n()`; it drives the language through the
provided instance. Drop the provide and a host can no longer set your plugin's language.
## 3. Reuse `library/` first
`library/` (imported as `@map/…`) is shared by every plugin. Its menu is
`documentation/reference.md`.
- **Reuse by import, never by copy.** `import { useMapInstance } from '@map/composables/useMapInstance.js'`.
- **Your own code goes in your `src/`**, imported by relative path. Nothing there reaches another plugin.
- **Don't edit `library/` to customise one plugin.** A library change reaches every plugin that
imports it, so it needs every consumer rebuilt. On a team with a build owner, hand library
changes to that owner rather than landing them yourself.
- **Graduate late.** Move code into `library/` only when a second plugin needs the same thing
and it takes props instead of hard-coded specifics.
## 4. The universal props — every plugin honours these
A plugin never decides these for itself; it reads them from whoever embeds it. The Plugin Studio
header sets all of them, which is how you test yours.
| Prop | Values | Channels, first found wins | Default |
|---|---|---|---|
| **Map engine** (`provider`) | `mapbox` · `maplibre` · `auto` (any case) | `profile-config` `"provider"` → `?provider=` in the page address → `window.MAP_PROVIDER`; an explicit choice always wins | `auto`: Mapbox when a token is available (any row below), MapLibre otherwise. Confirm in `library/composables/useMapProvider.js`, never assume it |
| **Language** (`locale`) | one of the 11 in `library/i18n/locales/` | `profile-config` `"locale"` (or `"lang"`) → `?lang=` / `?locale=` → `<html lang>` | English |
| **Mapbox token** (`tk`) | a public `pk.…` token | `profile-config` `"tk"` → `?tk=` on a generated page → `window.MAP_TOKEN`; generated pages also keep a token pasted once in the tree (browser storage) | none. The embed snippet's `YOUR_MAPBOX_TOKEN` placeholder counts as no token |
| **Token service** (`tokenUrl`) | a URL that returns a `pk.` token | `profile-config` `"tokenUrl"` (the element asks it when it has no token; the studio header's field feeds this) → on generated pages also `window.MAP_TOKEN_URL` → the build config's `tokenUrl` → same-origin `/api/maps/token` | none |
```html
<your-plugin profile-config='{"profile":"<screen>","provider":"maplibre","locale":"ar"}'></your-plugin>
```
Read them through the library (`resolveMapProvider`, the injected `mapboxToken`, the i18n
instance), never by parsing the URL yourself, and never fetch a token service yourself: the
library asks it, accepts only a `pk.` answer, and a Mapbox map waits for it before it starts.
**Your plugin must draw under MapLibre with no token at all.** `library/composables/useMapProvider.js` and
`library/ProfileLoader.vue` are the authority when this table and the code disagree.
## 5. Two engines: rules that keep MapLibre and Mapbox equal
Write every map feature once, so it works under both engines. These rules each come from a
real bug:
1. **Ask the map which engine it is.** Use `engineFor(map)` / `isMapboxMap(map)` from
`@map/composables/useMapProvider.js`, never a global that may still be defined after a switch.
2. **Some APIs differ in shape.** `getProjection()` is `{name}` on Mapbox but `{type}` or
`undefined` on MapLibre; `setProjection` takes `'mercator'` on Mapbox but `{type:'mercator'}` on
MapLibre; `setConfigProperty` and light presets exist only on Mapbox. Wrap these calls in `try`,
and never assume the result's shape. An uncaught throw silently skips everything after it.
3. **Mapbox-only paint properties drop the whole layer on MapLibre.** Never write
`'circle-emissive-strength'` (or any `*-emissive-strength`) literally. Spread
`...emissivePaint(map, kind)` from `useMapProvider.js`.
4. **Fonts must exist on the engine's glyph server.** Under MapLibre, a Mapbox font name
(`DIN Offc Pro`, `Arial Unicode MS`) gets a 404, and the layer draws **no text** with no error.
Spread `...labelFont(map, {…})` from `@map/maps/label-font.js` into every symbol layer.
5. **`mapbox://` sources don't load on MapLibre.** The country polygons come from the Mapbox tileset
`mapbox.country-boundaries-v1` under Mapbox. Under MapLibre, use the local file
`boundaries/world-adm0.geojson` under the assets base. It names the country code `iso3`, where
the tileset says `iso_3166_1_alpha_3`, so normalise once. `library/composables/useMapLayers.js`
(the "region polygons, per engine" block) is the worked example.
6. **Load assets from the assets base, never a page-relative path.** Use
`resolveAssetsBase(profileConfig)` from `@map/utils/embedBase.js`. A hard-coded `./assets`
works in the built folder and breaks under `bun run dev`.
7. **Prove your expressions parse under MapLibre without a browser.** MapLibre's own parser,
`@maplibre/maplibre-gl-style-spec`, is installed as a dependency of `maplibre-gl`. It is NOT
declared in `package.json`, so a lockfile change can remove it: if the import fails, add it as a
dev dependency rather than dropping the check.
```js
import { expression, featureFilter } from '@maplibre/maplibre-gl-style-spec'
const e = expression.createExpression(['match', ['get', 'status'], 'reached', '#2e7d32', '#c62828'])
if (e.result !== 'success') throw new Error(e.value.map(x => x.message).join('; '))
const admits = featureFilter(['==', ['get', 'iso3'], 'TUN']).filter({ zoom: 2 }, { properties: { iso3: 'TUN' } })
```
Run each paint and filter expression your layers use against a few real features from the data.
## 6. Localization and right to left
- **Every word on screen comes from a catalogue** in `library/i18n/locales/<code>/*.json`, the same
key in all 11 languages. Components use `t('file.key')`; code that runs outside the Vue app uses
`tStatic(path, locale)` from `@map/i18n/index.js`.
- **Add a key to all 11 catalogues at once.** `bun run check:i18n-parity` fails when one is missing;
`bun run check:i18n-never-translated` flags a key that was copied, not translated.
- **Names from the data stay as the data has them** (people groups, places); translate only the
interface.
- **Right to left belongs to your element, not the page.** Arabic mirrors the plugin's own box; the
library sets `dir` on it. Never write `document.documentElement.dir` from a plugin: an embedded
plugin that does flips the whole partner page.
## 7. Choose the capability level
Pick the lowest level that does the job. Declare levels 3–5 in `<screen>.meta.json` `"type"`;
levels 1 and 2 are detected on their own.
| Level | Name (`type`) | What it is | Example |
|---|---|---|---|
| 1 | single element (`single`) | one screen, one element | `plugin-designs/prayer-walk-planner/` |
| 2 | preset element (`parameterized`) | one screen, several named presets from `<screen>.instances.json` | `plugin-designs/doxa-simple-map/` |
| 3 | multi-view element (`monolithic`) | many views fused inside one element | `plugin-designs/doxa-research-map/` |
| 4 | composite element (`nested-elements-mfe`) | nests other elements, each still embeddable alone, wired by props and events | `plugin-designs/dashboard-plus-grid/` (the Big Picture tab) |
| 5 | app shell (`nested-mfe-spa`) | nests whole apps | `plugin-designs/doxa-maps-spa/` (the Research Gallery) |
## 8. Learn from the reference plugins
The seven production plugins are the reference: each shows what a good plugin does. Before you
write a rule's code, read the plugin that shows it best. Find code by the name given here, not by
line number, because lines move.
| Rule | Read this | What to look at |
|---|---|---|
| Entry: i18n installed **and** provided; one bundle, two tags | Prayer Progress: `plugin-designs/doxa-simple-map/index.js` | `configureApp` and its `app.provide('appI18n', …)` |
| Presets from one screen (capability 2) | Prayer Progress: `plugin-designs/doxa-simple-map/simple-map.instances.json` | Prayer / Engagement / Adoption as three embeddable pages |
| Engine-asymmetric APIs, guarded | Regions: `plugin-designs/regions-dashboard/src/RegionCountryMap.vue` | `projectionOf` and `setProjectionOn` — both shapes, inside `try` |
| Polygons both engines can draw; data from the assets base | Cities with Internet: `plugin-designs/cities-internet/connectivity-map.vue` | `resolveAssetsBase(…)`, the `boundaries/world-adm0.geojson` fetch (polygon data with no `mapbox://` source), and `resolveNamedStyle(…, provider)` for a basemap that matches the engine |
| Interface text from the catalogues | Gospel Resources: `plugin-designs/gospel-resources/resources-map.vue` | its interface text goes through `t('…')` (about 80 calls) |
| Basemap labels in the reader's language; right to left | Prayer Progress: `plugin-designs/doxa-simple-map/doxa-simple-map.vue` | the `name_<locale>` label switch and `isRTL` |
| Interop: one element reacts to another's events | Simple Research: `plugin-designs/research-map-plus-grid/research-map-plus-grid.vue` | the map listening for the grid's `groups-filtered` event |
| Composite element (capability 4) | Big Picture: `plugin-designs/dashboard-plus-grid/dashboard-plus-grid.meta.json` | `"composes"`: two elements, each still embeddable alone |
| One bundle, several embeddable screens | UPG-100: `plugin-designs/my-upg-100-list/` | `upg-100-mfe`, `upg-100-list` and `poster-wall` from one bundle |
| App shell (capability 5) | Research Gallery: `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue` | every production plugin as a tab, and the Experimental folder filled from the manifest |
`emissivePaint` and `labelFont` (section 5) have no production-plugin exemplar, because the
production plugins get them through the library's own layers. The starter uses both directly:
read the pin and label layers in `plugin-designs/template-bundle/template-map-a.vue`.
## 9. Develop in the Plugin Studio
```bash
bun install # once
bun run dev # the Plugin Studio at http://localhost:5173/
```
- Your plugin appears in the studio as soon as its folder exists, and edits reload live.
- One screen on its own: `http://localhost:5173/__leaf/<your-plugin>/<screen>`.
- Test the universal props from the studio header: switch the **engine**, the **language**
(include Arabic), and the **token** (none, a token, a token service). Your plugin must work in
every combination.
- `bun run check:dev-assets` checks that every map's data files load under the dev server.
## 10. Build: one step
```bash
BUNDLE=<your-plugin> bun x vite build # just your bundle → public/dx-maps/<your-plugin>/
BUILD_ALLOW=1 bun run build # every bundle, the tree, the manifest, the studio
```
The full build rewrites every plugin's output. It refuses to run without `BUILD_ALLOW=1`, and
on a team one agent owns it. Build only your own bundle unless you are that agent.
`public/dx-maps/manifest.json` then lists your plugin with ready-to-paste embed snippets.
## 11. The three outputs
| Output | What it is | How |
|---|---|---|
| **1. The web folder** | `public/dx-maps/`: every plugin, embeddable by **iframe** or **script tag**, from any host | copy the folder; snippets are in `manifest.json` |
| **2. The mobile app** | the same folder wrapped by Capacitor (`capacitor.config.json` `webDir: public/dx-maps`) | `bun run phone` (Android) |
| **3. The desktop app** | the same folder in Tauri (`deployment/src-tauri/`), a proof of concept | `bun run tauri:build` |
WordPress (`deployment/wordpress/`) embeds the web folder's snippets; it's experimental.
## 12. Done means
- `BUNDLE=<your-plugin> bun x vite build` succeeds, and your plugin is in `manifest.json`.
- It works in the Plugin Studio under **both engines**, **with and without a token**, in
**English and Arabic**.
- Every new string is in all 11 catalogues (`bun run check:i18n-parity`).
- Your layer expressions parse under MapLibre (section 5, rule 7).
- You changed nothing in `library/` or in another plugin's folder, or you handed that change to
the build owner.
## References (all in this repo)
- `plugin-designs/README.md`: authoring guide, production vs experimental
- `plugin-designs/template-bundle/`: the starter, and its README
- `documentation/reference.md`: the `@map/…` library menu and the profile types
- `documentation/how-to.md`: recipes, including swapping the map engine
- `library/composables/useMapProvider.js`: engine resolution and engine helpers
- `library/maps/label-font.js`: fonts per engine
- `library/i18n/index.js`: languages, `tStatic`, right to left