Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run

# Conflicts:
#	public/doxa-maps/doxa-research-map/doxa-research-map.js
#	public/doxa-maps/doxa-research-map/research-map/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
This commit is contained in:
doxa-maps-88655c70 2026-09-24 00:44:26 +00:00
commit ad534beb64
49 changed files with 1676 additions and 387 deletions

276
internal/fix-sri.mjs Normal file
View file

@ -0,0 +1,276 @@
#!/usr/bin/env node
/**
* fix-sri.mjs — rewrite every baked SRI hash in an ALREADY-BUILT tree, and nothing else.
*
* ★ WHY THIS EXISTS, and the premise it CORRECTS.
* The dispatch that asked for this said: "`bun run tree` regenerates manifest.json + leaf pages
* but NOT the baked SRI hashes, so `bun run check:sri` fails." MEASURED 2026-09-23, that is
* FALSE — generate-tree.mjs hashes each bundle (its `integrity` local, ~line 1054) and threads
* the value into every page it writes. Proven by poisoning one leaf's integrity
* (check:sri -> exit 1), running `bun run tree`, and re-checking (exit 0).
*
* So the cheap path was never missing. What was missing is the SIGNPOST and the SIZE:
*
* 1. THE SIGNPOST. check:sri's own failure text said "FIX: run a full `bun run build`". An agent
* that trusts the gate it just ran is then sent straight at the destructive path — whose first
* step deletes public/doxa-maps/ and tears the tree out from under every concurrent reader
* (measured 3+ times: a 461-file phantom deletion, several false "element did not mount"
* diagnoses). The gate was TEACHING the failure mode the build-lock guard exists to prevent.
* That message now names this script. A gate that diagnoses correctly and prescribes the
* dangerous remedy is worse than one that says nothing, because it is obeyed.
*
* 2. THE SIZE. `bun run tree` is cheap only next to a full build. MEASURED on this box: 2.7-4.5s,
* and it WRITES 1234 files under public/ (450 under public/doxa-maps) every single run,
* because it re-copies 401 runtime assets and re-emits all 48 pages. That churn is not free
* to your neighbours: internal/settled-tree-gate.mjs fails a tree with ANY write in the last
* 45s, so one `bun run tree` blinds every other agent's check:settled for three quarters of a
* minute, and those agents are told "do NOT measure or publish this tree."
*
* Worse, on a SHARED BRANCH `tree` regenerates pages FROM SOURCE — so it silently bakes a
* neighbour's in-flight .vue edits into leaf pages under YOUR change. Same family as the
* measured hazard of committing a built bundle on a shared branch.
*
* This script writes ONLY the pages whose hash actually disagrees. In the normal case that is
* one file, and when the tree is already consistent it is ZERO files — nothing to churn,
* nothing to un-settle, no source re-read at all.
*
* ★ WHAT IT DOES NOT DO — and this limit is the point, not an omission.
* It makes the HASHES AGREE. It does not make the BUNDLE CORRECT. If public/doxa-maps/x/x.js was
* built from stale source, this bakes a matching hash for stale code and check:sri goes green.
* That is equally true of `bun run tree`, and it is why this is a REPAIR verb, not a build verb:
* build the bundle first (`BUNDLE=x bun x vite build`), then run this to re-agree the leaves.
* Bundle FRESHNESS is a different question with its own gate — `bun run check:composite`.
* It also asserts nothing about how many leaves SHOULD exist; a tree gutted by a partial build
* is still unpublishable after this passes. That is settled-tree-gate.mjs's signal 4.
* The publish runbook still runs BOTH: `bun run check:settled && bun run check:sri`.
*
* WHAT IT REWRITES
* - every `<script src="<local>.js" integrity="...">` on every index.html in the tree
* - manifest.json's `bundles[].integrity` (what check:catalog-identity compares the LIVE bytes
* against — leaving this stale would trade one broken gate for another)
* - manifest.json's `bundles[].profiles[].embed.script` snippet, which carries its own integrity
* and is pasted VERBATIM by partners (deployment method 2)
* A tag with no integrity at all gets one, in generate-tree's exact emitted shape
* (`integrity="..." crossorigin="anonymous"`), because a leaf shipped without SRI is a silent
* downgrade of a protection this repo deliberately added, and check:sri cannot see it: that gate
* only compares the hashes that are PRESENT.
*
* USAGE
* node internal/fix-sri.mjs [treeDir ...] # default public/doxa-maps
* node internal/fix-sri.mjs --dry-run # report what would change, write nothing
* node internal/fix-sri.mjs --self-test # NEGATIVE test, see below
* Exit 0 = tree consistent. 1 = a referenced script DOES NOT EXIST (torn tree; cannot be
* repaired by rehashing — that needs the bundle built). 2 = tree not found.
*
* NEGATIVE-TESTED via `--self-test`: builds a synthetic tree in a temp dir carrying (a) a leaf with
* a WRONG integrity, (b) a leaf with NO integrity, (c) a manifest with a wrong bundle hash and a
* wrong embed snippet; requires sri-consistency-check to go RED on it FIRST (a fixer whose check
* could not fail proves nothing), then requires green, then requires a second run to write zero
* files. Temp dir only — a crash can never leave the real tree edited.
*/
import {
readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, rmSync,
} from 'node:fs'
import { join, dirname, resolve } from 'node:path'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const CHECKER = join(HERE, 'sri-consistency-check.mjs')
const argv = process.argv.slice(2)
const SELF_TEST = argv.includes('--self-test')
const DRY = argv.includes('--dry-run')
const TREES = argv.filter((a) => !a.startsWith('--'))
// One bundle .js is referenced by up to 5 leaves plus the manifest, and these are ~400KB IIFEs,
// so hashing per REFERENCE instead of per FILE made a 100-reference tree read 100 files. Cache by
// absolute path: a single run is one pass over an unchanging tree, and the caller is expected to
// build BEFORE repairing (see the header) — so a mid-run change to a .js is out of contract.
const HASHES = new Map()
function sri(file) {
let h = HASHES.get(file)
if (h === undefined) {
h = 'sha384-' + createHash('sha384').update(readFileSync(file)).digest('base64')
HASHES.set(file, h)
}
return h
}
function leafPages(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name)
if (e.isDirectory()) leafPages(p, out)
else if (e.name === 'index.html') out.push(p)
}
return out
}
/**
* Re-agree every local <script src> tag in `html` with the bytes on disk.
* `resolveSrc` maps an href to an absolute path (pages resolve against themselves; the manifest's
* embed snippets resolve against the tree root). Third-party (mapbox CDN) srcs are left alone —
* we cannot hash what we do not serve.
*/
function rewriteTags(html, resolveSrc, st) {
return html.replace(/<script\b[^>]*>/g, (tag) => {
const src = (tag.match(/\bsrc="([^"]+)"/) || [])[1]
if (!src || /^https?:|^\/\//.test(src)) return tag
const file = resolveSrc(src)
if (!file || !existsSync(file)) { st.missing.push(src); return tag }
const want = sri(file)
const have = (tag.match(/\bintegrity="(sha384-[^"]+)"/) || [])[1]
if (have === want) { st.agreed++; return tag }
if (have) {
st.rehashed++
return tag.replace(/\bintegrity="sha384-[^"]*"/, 'integrity="' + want + '"')
}
st.added++
// Preserve whatever trailing whitespace / self-closing slash the tag had.
const m = tag.match(/^([\s\S]*?)(\s*\/?)>$/)
return m[1] + ' integrity="' + want + '" crossorigin="anonymous"' + m[2] + '>'
})
}
function fixTree(root) {
const st = { agreed: 0, rehashed: 0, added: 0, missing: [], filesWritten: [] }
if (!existsSync(root)) {
console.error('FIX-SRI: tree not found: ' + root)
process.exit(2)
}
for (const page of leafPages(root)) {
const before = readFileSync(page, 'utf8')
const after = rewriteTags(before, (src) => resolve(dirname(page), src), st)
if (after !== before) {
if (!DRY) writeFileSync(page, after)
st.filesWritten.push(page)
}
}
const mf = join(root, 'manifest.json')
if (existsSync(mf)) {
const before = readFileSync(mf, 'utf8')
const m = JSON.parse(before)
for (const b of (m.bundles || [])) {
if (b.scriptUrl && !/^https?:|^\/\//.test(b.scriptUrl)) {
const js = resolve(root, b.scriptUrl)
if (existsSync(js)) {
const want = sri(js)
if (b.integrity !== want) { b.integrity = want; st.rehashed++ } else st.agreed++
} else st.missing.push(b.scriptUrl)
}
for (const p of (b.profiles || [])) {
if (!p.embed || typeof p.embed.script !== 'string') continue
p.embed.script = rewriteTags(p.embed.script, (src) => resolve(root, src), st)
}
}
// Same serialization generate-tree.mjs uses, so a repair produces no formatting diff.
const after = JSON.stringify(m, null, 2) + '\n'
if (after !== before) {
if (!DRY) writeFileSync(mf, after)
st.filesWritten.push(mf)
}
}
return st
}
function report(root, st) {
const verb = DRY ? 'WOULD rewrite' : 'rewrote'
console.log(
'FIX-SRI[' + root + ']: ' + st.agreed + ' already agreed, ' + verb + ' ' + st.rehashed +
' stale hash(es), added ' + st.added + ' missing one(s), ' +
(DRY ? 'would write ' : 'wrote ') + st.filesWritten.length + ' file(s).'
)
for (const f of st.filesWritten) console.log(' ' + (DRY ? '~ ' : '+ ') + f)
if (st.missing.length) {
const uniq = [...new Set(st.missing)]
console.error('\nFIX-SRI: ' + uniq.length + ' referenced script(s) DO NOT EXIST — rehashing cannot')
console.error(' repair this; the bundle has to be BUILT. Run, for each one:')
console.error(' BUNDLE=<name> bun x vite build # then re-run this script')
for (const s of uniq.slice(0, 10)) console.error(' missing: ' + s)
return 1
}
return 0
}
// ── NEGATIVE TEST ────────────────────────────────────────────────────────────────────────────────
function selfTest() {
const dir = join(tmpdir(), 'fix-sri-selftest-' + process.pid)
const fails = []
const ok = (cond, msg) => { if (!cond) fails.push(msg); else console.log(' ok — ' + msg) }
try {
rmSync(dir, { recursive: true, force: true })
mkdirSync(join(dir, 'b', 'p'), { recursive: true })
mkdirSync(join(dir, 'b', 'q'), { recursive: true })
writeFileSync(join(dir, 'b', 'b.js'), 'console.log("b")\n')
const REAL = sri(join(dir, 'b', 'b.js'))
const WRONG = 'sha384-' + 'A'.repeat(64)
// (a) a leaf whose baked hash is WRONG — the case check:sri exists to catch
writeFileSync(join(dir, 'b', 'p', 'index.html'),
'<!doctype html><script src="../b.js" integrity="' + WRONG + '" crossorigin="anonymous"></script>\n')
// (b) a leaf with NO integrity at all — invisible to check:sri, still a real downgrade
writeFileSync(join(dir, 'b', 'q', 'index.html'),
'<!doctype html><script src="../b.js"></script>\n')
// (c) a manifest with a wrong bundle hash AND a wrong paste-verbatim embed snippet
writeFileSync(join(dir, 'manifest.json'), JSON.stringify({
schema: 'self-test', bundles: [{
bundle: 'b', scriptUrl: './b/b.js', integrity: WRONG,
profiles: [{ profile: 'p', embed: { script: '<script src="./b/b.js" integrity="' + WRONG + '"></script>' } }],
}],
}, null, 2) + '\n')
// 1. THE CONTROL MUST BE ABLE TO GO RED. A fixer validated by a check that has only ever
// printed PASS proves nothing at all.
const red = spawnSync(process.execPath, [CHECKER, dir], { encoding: 'utf8' })
ok(red.status === 1, 'check:sri goes RED on the poisoned tree (exit ' + red.status + ', expected 1)')
ok(/b\/p\/index\.html/.test(red.stderr + red.stdout),
'it goes red for the RIGHT page (b/p/index.html named in the output)')
// 2. repair
const st = fixTree(dir)
ok(st.rehashed >= 2, 'rehashed the stale leaf AND the stale manifest bundle (rehashed=' + st.rehashed + ', expected >=2)')
ok(st.added === 1, 'added the ONE missing integrity (added=' + st.added + ')')
ok(st.missing.length === 0, 'no unresolvable script refs (missing=' + st.missing.length + ')')
// 3. GREEN AFTER — and not vacuously green: it must have had something to check.
const green = spawnSync(process.execPath, [CHECKER, dir], { encoding: 'utf8' })
ok(green.status === 0, 'check:sri goes GREEN after the repair (exit ' + green.status + ')')
const checked = Number((green.stdout.match(/checked (\d+) script reference/) || [])[1] || 0)
ok(checked >= 2, 'the green run actually MEASURED both leaves (checked=' + checked + ', expected >=2) — a tree with nothing to compare also prints PASS')
// 4. the manifest, which check:sri never reads, is repaired too
const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
ok(m.bundles[0].integrity === REAL, 'manifest bundles[].integrity now matches the bytes')
ok(m.bundles[0].profiles[0].embed.script.includes(REAL), 'the paste-verbatim embed snippet now matches the bytes')
ok(readFileSync(join(dir, 'b', 'q', 'index.html'), 'utf8').includes('integrity="' + REAL + '" crossorigin="anonymous"'),
'the integrity-less leaf gained one, in generate-tree\'s emitted shape')
// 5. IDEMPOTENT — the whole point is that a consistent tree costs ZERO writes, so running
// this never un-settles the tree for a neighbouring agent.
const again = fixTree(dir)
ok(again.filesWritten.length === 0, 'a second run writes 0 file(s) (wrote ' + again.filesWritten.length + ')')
} finally {
rmSync(dir, { recursive: true, force: true })
}
if (fails.length) {
console.error('\nFIX-SRI SELF-TEST: FAIL — ' + fails.length + ' assertion(s):')
for (const f of fails) console.error(' ✗ ' + f)
process.exit(1)
}
console.log('\nFIX-SRI SELF-TEST: PASS — the repair is proven against a check that was first proven to fail.')
}
if (SELF_TEST) {
selfTest()
} else {
let code = 0
for (const root of (TREES.length ? TREES : ['public/doxa-maps'])) {
code = report(root, fixTree(root)) || code
}
process.exit(code)
}

View file

@ -10,8 +10,12 @@
* THE MECHANISM (know this before you "just rebuild one bundle"):
* `BUNDLE=x bun x vite build` rewrites public/doxa-maps/x/x.js
* but the leaf page public/doxa-maps/x/x/index.html carries a BAKED
* integrity="sha384-..." that is only regenerated by `bun run tree`
* (internal/generate-tree.mjs), which runs ONLY in the full build.
* integrity="sha384-..." that the per-bundle build does not touch.
* Two things re-bake it. `bun run fix:sri` (internal/fix-sri.mjs) rewrites the hashes and
* NOTHING else. `bun run tree` (internal/generate-tree.mjs) re-bakes them as a side effect of
* regenerating every page from source. NEITHER needs the full build — an earlier version of
* this comment said generate-tree "runs ONLY in the full build", which is false (it is its own
* script) and is exactly why agents kept reaching for the destructive rebuild.
* So a single-bundle build leaves the bundle NEWER than its leaf. Publish that pair and
* the browser refuses to execute the script:
* "Failed to find a valid digest in the 'integrity' attribute for resource ..."
@ -109,8 +113,22 @@ if (bad.length) {
console.error(' bundle is : ' + b.got + '...')
}
console.error('\n CAUSE: a single-bundle `BUNDLE=x bun x vite build` rewrote the .js without')
console.error(' regenerating the leaf (only the FULL `bun run build` runs `bun run tree`).')
console.error(' FIX : run a full `bun run build` on a SETTLED tree, then re-check. Do NOT publish.')
console.error(' re-baking the hash into the leaf that loads it.')
console.error(' FIX : bun run fix:sri # rewrites ONLY the disagreeing hashes (~0.4s, writes')
console.error(' # only the offending pages, leaves the tree settled)')
console.error(' …then re-run this check. Do NOT publish until it passes.')
console.error('')
console.error(' ★ DO NOT reach for `bun run build` here. This message used to prescribe exactly')
console.error(' that, and it is the destructive remedy: its first step DELETES public/doxa-maps/,')
console.error(' tearing the tree out from under every other agent reading it (measured 3+ times,')
console.error(' incl. a 461-file phantom deletion and several false "element did not mount"')
console.error(' diagnoses). A full rebuild cannot fix anything here that rehashing does not —')
console.error(' the bundles are already built; only the recorded hashes disagree.')
console.error(' `bun run tree` also re-bakes them correctly, but it rewrites 1234 files under')
console.error(' public/ every run (measured), which fails your neighbours\' check:settled for 45s')
console.error(' and re-reads .vue SOURCE — so on a shared branch it bakes in whatever another')
console.error(' agent has half-finished. Prefer fix:sri; use tree when you actually want the')
console.error(' pages regenerated from source.')
process.exit(1)
}

View file

@ -14,14 +14,15 @@
* never interfere with each other.
*/
import { computed, defineAsyncComponent, provide, inject, watchEffect } from 'vue'
import { computed, provide, inject, watchEffect } from 'vue'
import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js'
import { SUPPORTED_LOCALES } from './i18n/index.js'
// QA corner: COMPILE-TIME gated (__DOXA_QA__, vite define). In public builds
// the flag is false → this whole branch (and the widget's code, including its
// platform references) is eliminated from the bundle. Platform builds
// (DOXA_QA=1) include it lazily.
const QaCorner = __DOXA_QA__
? defineAsyncComponent(() => import('./components/qa/QaCorner.vue'))
? retryableAsyncComponent(() => import('./components/qa/QaCorner.vue'))
: null
import PinHint from './components/hints/PinHint.vue'
import { useUIStore } from './stores/uiStore.js'
@ -183,7 +184,9 @@ const ProfileComponent = computed(() => {
console.error(`[ProfileLoader] Profile not found: "${profileName.value}". Available:`, Object.keys(profileModules))
return null
}
return defineAsyncComponent(profileModules[key])
// retryable: one ERR_NETWORK_CHANGED must not kill the profile for the page's life
// (see @map/composables/retryableAsyncComponent.js).
return retryableAsyncComponent(profileModules[key])
})
// ─── Provide parsed config to all descendants ────────────────────────────────

View file

@ -3,11 +3,11 @@ import { ref, computed, onMounted, onBeforeUnmount, watch, inject, toRef } from
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useLegendData } from '../../composables/legends/useLegendData.js'
import { defineAsyncComponent } from 'vue'
import { retryableAsyncComponent } from '../../composables/retryableAsyncComponent.js'
import LegendRows from './LegendRows.vue'
import PeopleGroupDetail from '../PeopleGroupDetail.vue'
// Async-import the heavier branches so the boot bundle stays lean.
const LegendMobile = defineAsyncComponent(() => import('./LegendMobile.vue'))
const LegendMobile = retryableAsyncComponent(() => import('./LegendMobile.vue'))
// Language-family tab is now handled by <SemanticTreeLegend> mounted directly
// in research-map.vue as a standalone sibling (PPLR's panel chrome owns its
// positioning + collapse-to-pill). LegendDesktop only renders the other tabs

View file

@ -60,7 +60,11 @@ function approxIndices(haystack, needle, threshold) {
const n = needle.toLowerCase()
const m = n.length
if (!m || !h.length) return null
const maxErr = Math.floor(threshold * m)
// ★ ABSOLUTE CAP AT 2 EDITS (the classic did-you-mean bound). threshold*len alone grows with
// the term: at 8+ chars it allowed 3 errors, and 3-edit windows are how 'Canadian' -- a country
// with ZERO rows in this dataset -- raked 47 rows of unrelated facets. Every real typo the
// Driver reported (Bhutann, Banladesh, Austrian->Austria, Canadian->Canada) is 1-2 edits.
const maxErr = Math.min(2, Math.floor(threshold * m))
if (maxErr <= 0) return null
// dp[j] = min errors matching the whole prefix n[0..i] ending at text position j.
// Row 0 is all zeros: the match may START anywhere (that is the "search" variant).
@ -101,6 +105,59 @@ export function exactMatches(text, term) {
if (!raw) return false
return raw.toLowerCase().includes(q.toLowerCase())
}
/**
* ★ TIER 2 — FACET-VALUE RESOLUTION (Driver 2026-09-24: "if I search the name of the country
* and add n to it, like Austrian or Canadian, I would like for there to be a selection of that
* country in the search results -- the table under the map shows the result for that country").
* Given the DISTINCT values of a facet (country labels, region names...), return the values the
* term fuzzy-resolves to. A term like 'Austrian' then selects rows by country === 'Austria' --
* byte-identical to picking the country as a Place filter -- instead of raking every text field
* for <=k-error neighbours. Precise where fuzzy rescue used to be noisy ('Banladesh' -> exactly
* the Bangladesh rows).
*/
export function resolveFuzzyValues(term, values, { threshold = 0.4 } = {}) {
const q = String(term ?? '').trim().toLowerCase()
if (!q) return []
const maxErr = Math.min(2, Math.floor(threshold * q.length)) // same did-you-mean cap as approxIndices
const out = []
for (const v of values) {
if (!v) continue
const s = String(v).toLowerCase()
// WHOLE-VALUE distance, deliberately NOT the windowed matcher: resolution asks "is this
// term a misspelling of this VALUE" ('austrian' ~ 'austria', 1 edit), where the windowed
// matcher asks "does a neighbourhood of this term appear ANYWHERE in this text" -- the
// question that made fuzzy rescue noisy. Length pre-filter keeps it O(values).
if (Math.abs(s.length - q.length) > maxErr) continue
const d = levenshtein(s, q, maxErr)
if (d <= maxErr) out.push({ value: v, distance: d })
}
// ★ BEST MATCH WINS, not everything under the cap. Measured: 'Canadian' at k=3 admitted 47
// rows across unrelated facets (first card PERU) because every value within 3 edits of any
// accessor resolved. The user misspelled ONE thing; return the closest value(s) only --
// ties at the same minimal distance stay (genuine ambiguity), everything farther drops.
if (!out.length) return []
const best = Math.min(...out.map((o) => o.distance))
return out.filter((o) => o.distance === best).map((o) => o.value)
}
// Banded Levenshtein with early exit past maxErr.
function levenshtein(a, b, maxErr) {
if (a === b) return 0
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
const cur = new Array(b.length + 1)
for (let i = 1; i <= a.length; i++) {
cur[0] = i
let rowMin = i
for (let j = 1; j <= b.length; j++) {
cur[j] = Math.min(prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), prev[j] + 1, cur[j - 1] + 1)
if (cur[j] < rowMin) rowMin = cur[j]
}
if (rowMin > maxErr) return maxErr + 1
for (let j = 0; j <= b.length; j++) prev[j] = cur[j]
}
return prev[b.length]
}
export function fuzzyMatches(text, term, { threshold = 0.4, minMatchCharLength = 3 } = {}) {
const raw = String(text ?? '')
const q = String(term ?? '').trim()
@ -139,7 +196,41 @@ export default class Fuse {
if (matches.length) exact.push({ item, matches })
}
if (exact.length || exactOnly) return exact
// TIER 2: the zero-result typo rescue (k-errors edit distance).
// TIER 2: facet-VALUE resolution -- resolve the term against each key's distinct values
// ('Austrian' -> the value 'Austria') and return the items carrying a resolved value.
// See resolveFuzzyValues above; runs before the generic per-item rescue because selecting
// by the resolved value is exact (facet-equivalent) where raking all text is noisy.
// Resolve across the DISTINCT values of ALL keys in one pool so the global best match
// wins (per-key pools would let a distance-3 language beat nothing while a distance-2
// country exists under another key).
const pool = new Map() // value -> Set(keys carrying it)
for (const key of this.keys) {
for (const item of this.list) {
const raw = getByPath(item, key)
if (typeof raw !== 'string' || !raw) continue
if (!pool.has(raw)) pool.set(raw, new Set())
pool.get(raw).add(key)
}
}
const winners = new Set(resolveFuzzyValues(q, [...pool.keys()], { threshold: this.threshold }))
const hitByKey = new Map()
for (const v of winners) for (const key of pool.get(v)) {
if (!hitByKey.has(key)) hitByKey.set(key, new Set())
hitByKey.get(key).add(v)
}
if (hitByKey.size) {
const resolved = []
for (const item of this.list) {
const matches = []
for (const [key, hits] of hitByKey) {
const raw = getByPath(item, key)
if (typeof raw === 'string' && hits.has(raw)) matches.push({ key, indices: [[0, raw.length - 1]] })
}
if (matches.length) resolved.push({ item, matches })
}
if (resolved.length) return resolved
}
// TIER 3: the zero-result typo rescue (k-errors edit distance).
const results = []
for (const item of this.list) {
const matches = []

View file

@ -0,0 +1,30 @@
/**
* retryableAsyncComponent.js — SHARED (@map/composables/retryableAsyncComponent.js).
*
* ★ WHY (card BUG-LAZY-PLUGIN-DEATH-ON-A-SINGLE-FAILED-IMPORT). Vue's defineAsyncComponent
* caches a REJECTED loader promise: once a dynamic import() fails, that component is dead for
* the life of the page — every later render replays the same rejection. The Driver hit exactly
* this: one net::ERR_NETWORK_CHANGED event (wifi switch / VPN / sleep-wake, a TRANSPORT blip,
* not a 404) failed five unrelated module fetches at the same moment, and doxa-simple-map +
* doxa-research-map stayed "not loading" until a full reload — a working product reading as
* two broken plugins.
*
* This is the ONE shared wrapper every lazy component goes through instead. It uses
* defineAsyncComponent's own onError contract — retry() re-invokes the loader, which issues a
* FRESH import() request — with 3 attempts and a short growing backoff, which is precisely the
* shape a transient network blip needs. If all attempts fail (a real 404, a dead server), it
* fails for good: retrying forever would hide genuine breakage.
*
* Usage (drop-in): retryableAsyncComponent(() => import('./Heavy.vue'))
*/
import { defineAsyncComponent } from 'vue'
export function retryableAsyncComponent(loader) {
return defineAsyncComponent({
loader,
onError(error, retry, fail, attempts) {
if (attempts <= 3) setTimeout(retry, attempts * 500)
else fail()
},
})
}

View file

@ -58,6 +58,7 @@
"check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs",
"check:sri": "node internal/sri-consistency-check.mjs",
"fix:sri": "node internal/fix-sri.mjs",
"check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
"check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not fall behind library/. Compares the index against the newest library SOURCE (not a bundle — this generator scans library/ directly, with no build step between). Fix: bun run library-index",

View file

@ -48,7 +48,8 @@
* Tabs are baked in — edit the ALL_TABS constant in this file to change them.
*/
import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, markRaw, nextTick, defineAsyncComponent, toRef } from 'vue'
import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, markRaw, nextTick, toRef } from 'vue'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
// ─── Composables (framework) ─────────────────────────────────────────────────
import { useMapInstance } from '@map/composables/useMapInstance.js'
@ -127,7 +128,7 @@ import LegendDesktop from '@map/components/legends/LegendDesktop.vue'
import LegendTools from '@map/components/legends/LegendTools.vue'
// PERF: LegendMobile only renders below the @media(max-width:767px) breakpoint.
// Defer the import so desktop users never load it.
const LegendMobile = defineAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
const LegendMobile = retryableAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
// Map toolbar — canonical modular pattern from the read-only simple-map mfe.
// MapToolbar is the slot-wrapper; the individual button components are children.
@ -150,7 +151,7 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
// PERF: PeopleGroupDetail (~115 KB with its CSS bundle) only renders after
// the user clicks a pin. Async-import it so it stays off the critical boot.
const PeopleGroupDetail = defineAsyncComponent(() => import('@map/components/PeopleGroupDetail.vue'))
const PeopleGroupDetail = retryableAsyncComponent(() => import('@map/components/PeopleGroupDetail.vue'))
// New components written in this same wave:
import ResearchMapSideMenu from '@map/components/ResearchMapSideMenu.vue'
@ -176,7 +177,7 @@ import { RESEARCH_LEGEND_OPTIONS } from '@map/composables/legends/researchLegend
// PERF: PosterDialog only renders when the user opens the poster export
// flow. Async-import it so the dialog's deps don't load on every map boot.
const PosterDialog = defineAsyncComponent(() => import('@map/components/poster/PosterDialog.vue'))
const PosterDialog = retryableAsyncComponent(() => import('@map/components/poster/PosterDialog.vue'))
// ─── Shadow-DOM style injection ──────────────────────────────────────────────
useShadowStyles(`

View file

@ -36,7 +36,8 @@
* machine key "monolithic" in research-map.meta.json) — see documentation/reference.md "Profile types".
*/
import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, markRaw, toRaw, nextTick, defineAsyncComponent, toRef } from 'vue'
import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, markRaw, toRaw, nextTick, toRef } from 'vue'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
// ─── Composables (framework) ─────────────────────────────────────────────────
import { useMapInstance } from '@map/composables/useMapInstance.js'
@ -154,7 +155,7 @@ import LegendDesktop from '@map/components/legends/LegendDesktop.vue'
import LegendTools from '@map/components/legends/LegendTools.vue'
// PERF: LegendMobile only renders below the @media(max-width:767px) breakpoint.
// Defer the import so desktop users never load it.
const LegendMobile = defineAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
const LegendMobile = retryableAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
// Map toolbar — canonical modular pattern from the read-only simple-map mfe.
// MapToolbar is the slot-wrapper; the individual button components are children.
@ -177,7 +178,7 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
// PERF: PeopleGroupDetail (~115 KB with its CSS bundle) only renders after
// the user clicks a pin. Async-import it so it stays off the critical boot.
const PeopleGroupDetail = defineAsyncComponent(() => import('@map/components/PeopleGroupDetail.vue'))
const PeopleGroupDetail = retryableAsyncComponent(() => import('@map/components/PeopleGroupDetail.vue'))
// New components written in this same wave:
import ResearchMapSideMenu from '@map/components/ResearchMapSideMenu.vue'
@ -203,7 +204,7 @@ import { RESEARCH_LEGEND_OPTIONS } from '@map/composables/legends/researchLegend
// PERF: PosterDialog only renders when the user opens the poster export
// flow. Async-import it so the dialog's deps don't load on every map boot.
const PosterDialog = defineAsyncComponent(() => import('@map/components/poster/PosterDialog.vue'))
const PosterDialog = retryableAsyncComponent(() => import('@map/components/poster/PosterDialog.vue'))
// ─── Shadow-DOM style injection ──────────────────────────────────────────────
useShadowStyles(`

View file

@ -12,7 +12,8 @@
* Shadow DOM safe: all CSS via useShadowStyles, geocoder CSS injected at runtime.
*/
import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, toRef, defineAsyncComponent, markRaw, toRaw } from 'vue'
import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, toRef, markRaw, toRaw } from 'vue'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import { useI18n } from 'vue-i18n'
import { RTL_LOCALES } from '@map/i18n/index.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
@ -27,7 +28,7 @@ import { buildColorExpression, getColorStrategy, COLOR_MODES } from '@map/colors
import { FULL_PRAYER_THRESHOLD } from '@map/colors/prayer-progress.js'
import { mapDefaults } from '@map/constants/mapDefaults.js'
import LegendDesktop from '@map/components/legends/LegendDesktop.vue'
const LegendMobile = defineAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
const LegendMobile = retryableAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
import { createPplrInstance, provideInstance } from '@map/composables/usePplrInstance.js'
import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js'

View file

@ -21,7 +21,8 @@
* data lazy-import()-ed, ONE source + match paint (not 26 layers), theme toggle
* via a single setStyle(style,{diff:false}).
*/
import { inject, provide, ref, shallowRef, markRaw, computed, watch, nextTick, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
import { inject, provide, ref, shallowRef, markRaw, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
// The shared mobile bottom-sheet. Fed this profile's own tree through the `nodes`
// override so the mobile legend is the SAME component every other map uses.
@ -46,7 +47,7 @@ import { createPplrInstance, provideInstance } from '@map/composables/usePplrIns
// its OWN visibility/close via uiStore.selectPeopleGroup(feature|null) — it has
// NO @close emit, so we gate the panel on uiStore.selectedPeopleGroup and let
// its built-in close button clear the store.
const PeopleGroupDetail = defineAsyncComponent(() => import('@map/components/PeopleGroupDetail.vue'))
const PeopleGroupDetail = retryableAsyncComponent(() => import('@map/components/PeopleGroupDetail.vue'))
// Shared map-control components (CONSUME — never edit src/). Same set + ordering
// the research-map mounts (research-map.vue:1838-1845). MapToolbar is the slot
// wrapper that self-positions top-right; the buttons are its children.
@ -271,14 +272,35 @@ const LEGEND_TITLE = 'My UPG 100 List — Legend'
// by coincidence — the catalog means the ROP-2.5 sense, not this bundle's). Prefixed ids own no
// catalog entry, so the labels below are what render. If these tabs are ever translated, add
// `upg100Region`/`upg100Zone`/`upg100Group` to tabs.json rather than reusing a generic key.
// ★ "Zone" RENAMED, ORDER DELIBERATELY UNCHANGED (2026-09-23).
// Driver: "the user does not know what zones means" — the thing the map paints IS
// a UPG-100 list (27 of them); "zone" was our internal word leaking into the UI.
// So the LABEL now names what it shows. Region stays as what it actually is: a way
// to SORT those lists by world area when you want that.
//
// ⚠ DO NOT REORDER THESE TO MAKE THE LIST TAB OPEN FIRST. Tab INDEX is coupled to
// tree DEPTH in the shared legend: `activeTab = Math.min(sel.depth, …)` and again
// on breadcrumb (SemanticTreeLegend ~231 and ~418). The tree nests region(0) →
// zone(1) → group(2), so moving zone to index 0 would make selecting a zone jump
// to the Region tab. Seven profiles share that component.
// Opening on the list tab is done the way the codebase already provides for it:
// `:default-tab="1"` at the call site — the prop country-posters added for exactly
// this ("it opens to the country's semantic level in the legend").
//
// NOTE the warning above: these prefixed ids own no catalog entry, so THESE labels
// are what render. If the packet is ever translated, add upg100Zone / upg100Region
// / upg100Group to tabs.json rather than reusing a generic key.
const LEGEND_TABS = [
{ id: 'upg100Region', label: 'Region',
info: 'Region — the broadest grouping, one row per world area (Africa, India, Middle East, Deaf, …). Counts are the UUPGs and population summed across every zone beneath it.' },
{ id: 'upg100Zone', label: 'Zone',
info: 'Zone — the prayer zones inside a region (e.g. Frontier Asia (uncovered) within Asia, or the Indian states within India). This is the level the map paints and colours.' },
info: 'Region — sort the UPG-100 lists by world area (Africa, India, Middle East, Deaf, …). Counts are the UUPGs and population summed across every list beneath it.' },
{ id: 'upg100Zone', label: 'UPG-100 Lists',
info: 'UPG-100 Lists — each one is a prayer list of the unreached people groups in an area, and is what the map paints and colours. Every list prints as its own packet.' },
{ id: 'upg100Group', label: 'People Group',
info: 'People Group — the individual unreached groups inside a zone, largest prayer need first. Each map dot is one of these.' },
info: 'People Group — the individual unreached groups inside a list, largest prayer need first. Each map dot is one of these.' },
]
// The generation the legend OPENS on: index 1 = the UPG-100 Lists tab. A reader
// should meet the lists first and reach for Region only to sort them.
const LEGEND_DEFAULT_TAB = 1
const geocoderContext = 'people-groups'
const geocoderPlaceholder = 'Search people group, zone, or territory…'
@ -1810,6 +1832,7 @@ onBeforeUnmount(() => {
v-if="legendNodes.length"
:nodes="legendNodes"
:tabs="LEGEND_TABS"
:default-tab="LEGEND_DEFAULT_TAB"
:columns="['count', 'pop']"
:title="LEGEND_TITLE"
:export-enabled="true"

View file

@ -22,9 +22,11 @@
* config/countries-meta.ts. Copied rather than imported for the same reason stated there — and
* kept field-for-field identical so both tabs roll up the same way.
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import BarChart from '@map/components/charts/BarChart.vue'
import DataTable from '@map/components/tables/DataTable.vue'
import RegionCountryMap from './src/RegionCountryMap.vue'
const props = defineProps({
profileConfig: { type: String, default: '' },
@ -32,16 +34,40 @@ const props = defineProps({
languageCode: { type: String, default: '' },
})
// ★ THE CONFIG ARRIVES BY INJECT, NOT AS A PROP (measured 2026-09-23).
// The custom element this bundle registers IS ProfileLoader (index.js:41), which parses the
// profile-config ATTRIBUTE itself and hands the parsed object to its descendants via
// provide('profileConfig', config) (ProfileLoader.vue:195). So a profile component's own
// `profileConfig` STRING PROP is always empty, and every value read off it silently fell back
// to a default: prayBaseUrl, languageCode, instanceId and assetsBase alike. Nothing looked
// broken because the defaults happened to be right -- until assetsBase mattered, and the
// vendored Tabulator 404'd twice with no error while the table just never drew.
// Inject first, fall back to the prop so a direct <regions-dashboard profile-config='...'>
// outside ProfileLoader still works.
const injectedCfg = inject('profileConfig', null)
const cfg = computed(() => {
const inj = injectedCfg && (injectedCfg.value !== undefined ? injectedCfg.value : injectedCfg)
if (inj && typeof inj === 'object') return inj
if (!props.profileConfig) return {}
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
const instanceId = computed(() => cfg.value.instanceId || '')
// THE CAP-4 ASSET-PATH LESSON (BUG-UPG100MFE-ASSET-PATH, and it bit again here): DataTable
// injects the vendored Tabulator from a path RELATIVE to the document. Nested inside the
// regions-mfe composite the document is .../regions-mfe/index.html, so './assets' resolved to
// .../regions-mfe/assets/vendor/tabulator/* and 404'd twice with no visible error -- the table
// simply never drew. The generator bakes the right base per page depth and regions-mfe.vue
// already forwards it in INHERITED_PAGE_KEYS; this reads it instead of trusting the default.
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
// ── state ───────────────────────────────────────────────────────────────────
const regions = ref([])
// The rollup aggregates records away, and the map needs them one by one. Kept flat and
// pre-parsed once here rather than re-fetched inside the map child, as DX-life's CountryMap
// does -- one request per element, and the pins provably describe the same rows as the stats.
const points = ref([])
const loading = ref(true)
const loadFailed = ref(false)
// The current selection, as { regionSlug, countrySlug } — seeded from profile-config so a
@ -52,7 +78,10 @@ const sel = ref({
})
// ── identical field list + rollup to tab 1 ──────────────────────────────────
const FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
// A SUPERSET of tab 1's list: name + slug are added for the map popups and the country
// people-group list. Every ROLLUP field is unchanged, so the two tabs still roll up
// identically -- the extra fields are read by the map, never by addToStats().
const FIELDS = 'name,slug,country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
function toNumber(v) {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0)
@ -136,6 +165,16 @@ function formatNumber(n) {
return Number(n || 0).toLocaleString('en-US')
}
// Which size step a stat value needs so it is never CUT OFF. Their 47.68px fits up to 10
// characters in the 240px card at Bebas's width; our condensed fallback is wider, so the step
// down starts at 10. Only ever shrinks -- every value DX-life itself renders keeps their size.
function lenBucket(v) {
const n = String(v).length
if (n >= 12) return 'xlong'
if (n >= 10) return 'long'
return ''
}
// ── the selected subject ────────────────────────────────────────────────────
const selectedRegion = computed(() =>
regions.value.find((r) => r.slug === sel.value.regionSlug) || null
@ -201,6 +240,190 @@ const chartTitle = computed(() =>
: 'People groups by region'
)
// ── DX-life's RegionDetail, field for field ──────────────────────────────────
// Ported from root-dx-life/doxa-life/app/components/public/RegionDetail.vue (read-only clone,
// standing law). Every figure below is one THEIR component also computes, from the rollup we
// already had -- nothing here is a new metric we invented to fill a card.
// Their prayer split, RegionDetail.vue:24-26: fully covered (100+ committed), partially covered
// (1+), and nobody yet. partial/none are DERIVED, never fetched, so the three always total count.
const partialPrayerCount = computed(() => Math.max(0, (subject.value.stats.withPrayer || 0) - (subject.value.stats.withFullPrayer || 0)))
const noPrayerCount = computed(() => Math.max(0, (subject.value.stats.count || 0) - (subject.value.stats.withPrayer || 0)))
// RegionDetail.vue:28-30. Capped at 100 for the same reason theirs is: a bar wider than its
// track is a rendering bug, not a data story.
function percentOfSubject(count) {
const total = subject.value.stats.count || 0
return total ? Math.min(100, (count / total) * 100) : 0
}
// Their four headline stats, RegionDetail.vue:52-69: People Groups / Countries / Population /
// Unengaged. A COUNTRY subject has no countryCount, and their own country page does not show one
// either -- so that slot becomes Engaged, which the rollup measures for a country identically.
const stats = computed(() => {
const s = subject.value.stats || emptyStats()
const unengaged = Math.max(0, (s.count || 0) - (s.engaged || 0))
const four = [{ label: 'People Groups', value: formatNumber(s.count) }]
if (subject.value.kind === 'country') four.push({ label: 'Engaged', value: formatNumber(s.engaged) })
else four.push({ label: 'Countries', value: formatNumber(subject.value.kind === 'all' ? totalCountryCount.value : (selectedRegion.value?.countryCount || 0)) })
four.push({ label: 'Population', value: formatNumber(s.population) })
four.push({ label: 'Unengaged people groups', value: formatNumber(unengaged) })
return four
})
const totalCountryCount = computed(() => regions.value.reduce((n, r) => n + (r.countryCount || 0), 0))
// Their three goal cards, RegionDetail.vue:88-141. The goal SENTENCES are theirs verbatim --
// they are the DOXA goals, not copy we get to reword.
const adoptedPct = computed(() => percentOfSubject(subject.value.stats.adopted || 0))
const engagedPct = computed(() => percentOfSubject(subject.value.stats.engaged || 0))
// Their per-country table, RegionDetail.vue:144-171 -- five columns, same order.
// A country subject keeps its REGION's rows with itself highlighted, for the reason the chart
// already gives: one lone row is a number without a scale.
const tableRows = computed(() => {
if (subject.value.kind === 'all') {
return regions.value.map((r) => ({
key: r.slug, name: r.label || 'Other', count: r.count, withPrayer: r.withPrayer,
adopted: r.adopted, engaged: r.engaged, isSelected: false, kind: 'region',
}))
}
const r = selectedRegion.value
const rows = r ? r.countries : []
return rows.map((c) => ({
key: c.slug, name: c.name, count: c.count, withPrayer: c.withPrayer,
adopted: c.adopted, engaged: c.engaged,
isSelected: !!selectedCountry.value && c.slug === selectedCountry.value.slug, kind: 'country',
}))
})
const tableTitle = computed(() => {
if (subject.value.kind === 'all') return 'All regions'
if (subject.value.kind === 'country') return 'Countries in ' + (selectedRegion.value?.label || 'this region')
return 'Countries in this region'
})
const tableFirstCol = computed(() => (subject.value.kind === 'all' ? 'Region' : 'Country'))
// Their summary line, RegionDetail.vue:32-36 / the list's own. Kept as a %token% template for
// the same reason tab 1 keeps it -- it is how their i18n carries this string.
const summaryLine = computed(() => {
const s = subject.value.stats || emptyStats()
if (subject.value.kind === 'country') {
return formatNumber(s.count) + ' people groups \u00b7 ' + (selectedCountry.value?.region?.label || 'Other')
}
const countries = subject.value.kind === 'all' ? totalCountryCount.value : (selectedRegion.value?.countryCount || 0)
return '%count% people groups in %countries% countries'
.replace('%count%', formatNumber(s.count))
.replace('%countries%', formatNumber(countries))
})
// ★ THE NO-SELECTION QUESTION, answered from what DX-life actually does.
// There is NO dashboard-without-a-subject route on their site: /regions is the LIST, and
// /regions/<slug> ALWAYS has a subject -- pages/regions/[slug].vue throws a 404 when the slug
// matches neither a region nor a country. So their answer is "that state does not exist; you are
// on the list instead". A 2-tab element cannot borrow that answer, because tab 2 is reachable
// before anything is clicked. Keeping their INVARIANT (the dashboard always has a subject) and
// making the widest honest subject the default gives: with nothing selected the subject is ALL
// REGIONS -- the same dashboard, every figure summed, and the table switched from countries to
// regions so the page still compares like with like. The prompt below then says how to narrow it.
const isAll = computed(() => subject.value.kind === 'all')
// ── the map's subject: which countries to highlight, and how to frame them ──
// DX-life gets centre/zoom/bounds from config/countries-meta.ts, a build-time table we do not
// have. Deriving the frame from the FOCUS POINTS themselves is better, not a workaround: their
// own comment at CountryMap.vue:177-178 says bounds take precedence over centre/zoom because the
// frame should fit the real data rather than "the build-time approximation".
const focusCodes = computed(() => {
if (selectedCountry.value) return [selectedCountry.value.code]
if (selectedRegion.value) return selectedRegion.value.countries.map((c) => c.code)
return regions.value.flatMap((r) => r.countries.map((c) => c.code))
})
const mapBounds = computed(() => {
const focus = new Set(focusCodes.value)
const pts = points.value.filter((p) => focus.has(p.code))
if (pts.length < 2) return null
let w = Infinity, e = -Infinity, s2 = Infinity, n = -Infinity
for (const p of pts) {
if (p.lng < w) w = p.lng
if (p.lng > e) e = p.lng
if (p.lat < s2) s2 = p.lat
if (p.lat > n) n = p.lat
}
if (!Number.isFinite(w) || (w === e && s2 === n)) return null
return [[w, s2], [e, n]]
})
// Only used when bounds cannot be formed (a single people group, or none plotted yet).
const mapCenter = computed(() => {
const focus = new Set(focusCodes.value)
const pts = points.value.filter((p) => focus.has(p.code))
if (!pts.length) return [20, 10]
return [pts.reduce((a, p) => a + p.lng, 0) / pts.length, pts.reduce((a, p) => a + p.lat, 0) / pts.length]
})
const mapZoom = computed(() => (selectedCountry.value ? 4 : 2))
// Their map notes, verbatim, with the same %token% substitution their i18n uses.
// RegionDetail.vue:38-42 and CountryDetail.vue:29-33.
const mapNote = computed(() => {
if (selectedCountry.value) {
return 'There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.'
.replace('%count%', formatNumber(selectedCountry.value.count))
.replace('%country%', selectedCountry.value.name)
}
const label = subject.value.title
const countries = subject.value.kind === 'all' ? totalCountryCount.value : (selectedRegion.value?.countryCount || 0)
return '%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.'
.replace('%region%', label)
.replace('%count%', formatNumber(subject.value.stats.count))
.replace('%countries%', formatNumber(countries))
})
// ── the country page's people-group list ────────────────────────────────────
// CountryDetail.vue:63-69 nests UupgsList locked to the country. That component is 662 lines of
// search + eight filter dropdowns + photo cards, and this repo ALREADY ships that element as the
// people-groups-grid bundle -- re-implementing it here would be a third copy. So the country page
// gets the same INFORMATION through our own table pattern (@map/components/tables/DataTable.vue,
// which carries the mobile column-collapse and row-detail behaviour the Driver asked for once
// already). Nesting the real people-groups-grid element is the right end state and is a cap-4
// composition question, not a styling one.
const countryTable = computed(() => {
const c = selectedCountry.value
if (!c) return null
const rows = points.value
.filter((p) => p.code === c.code)
.sort((a, b) => (b.population - a.population) || a.name.localeCompare(b.name))
.map((p) => ({ name: p.name, population: p.population, committed: p.committed }))
return {
meta: { title: c.name + ' people groups' },
columns: [
{ field: 'name', title: 'People Group' },
{ field: 'population', title: 'Population', hozAlign: 'right', formatter: 'money',
formatterParams: { precision: 0, thousand: ',' } },
{ field: 'committed', title: 'People Praying', hozAlign: 'right', formatter: 'money',
formatterParams: { precision: 0, thousand: ',' } },
],
rows,
}
})
// ── in-dashboard navigation ─────────────────────────────────────────────────
// Their back-link (RegionDetail.vue:48) goes to /regions. We have no router, so it drops the
// selection, which lands on the ALL REGIONS subject -- the closest thing this element has to
// their index page, and reachable without leaving the tab.
function clearSelection() {
sel.value = { regionSlug: '', countrySlug: '' }
}
// Their table's first column is a NuxtLink to /regions/<slug> (RegionDetail.vue:166). Here the
// same click re-points THIS dashboard: a region row on the all-regions table drills into that
// region, a country row into that country. Same destination, no navigation.
function selectFromTable(row) {
if (row.kind === 'region') sel.value = { regionSlug: row.key, countrySlug: '' }
else sel.value = { regionSlug: sel.value.regionSlug || selectedRegion.value?.slug || '', countrySlug: row.key }
}
// ── cross-pane seam: the composite forwards tab 1's selection here ──────────
const SELECT_EVENT = 'doxa-regions-dashboard:select'
@ -223,10 +446,17 @@ async function loadRegions() {
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
regions.value = summarizeRegions(posts)
points.value = posts.map((p) => ({
name: p.name || '', slug: p.slug || '',
code: p.country_code?.value || '', country: p.country_code?.label || '',
lng: parseFloat(p.longitude), lat: parseFloat(p.latitude),
committed: toNumber(p.people_committed), population: toNumber(p.population),
})).filter((p) => Number.isFinite(p.lng) && Number.isFinite(p.lat))
} catch (error) {
console.error('[region-dashboards] fetch error:', error)
loadFailed.value = true
regions.value = []
points.value = []
} finally {
loading.value = false
}
@ -245,35 +475,163 @@ onBeforeUnmount(() => {
// the two tabs does not change the surface under the reader.
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
useShadowStyles(
// EVERY value below is DX-life's own, resolved against their 10px rem root
// (app/assets/styles/base/_globals.scss html{font-size:var(--font-size-base)}, and
// tokens/semantic/_typography.scss $font-size-base:10px). Sources, one per block:
// .container composition/_container.scss
// .card blocks/_cards.scss:9-37
// .switcher composition/_switcher.scss
// .progress-bar blocks/_progress-bar.scss
// .region-* RegionDetail.vue's own scoped block
// colours tokens/semantic/_colors.scss
// Spacing is the 1.33 modular scale: 2xs 5.65 / xs 7.52 / sm 13.3 / md 17.7 / lg 23.5 /
// xl 31.3 / 2xl 41.6 / 3xl 55.3. Type is the 1.25 scale off 10px: sm 15.6 / md 19.5 /
// lg 24.4 / 2xl 38.1 / 3xl 47.7 / 4xl 59.6 / 5xl 74.5.
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
':host{ display:block; width:100%; height:100%; min-height:320px; }' +
'.rd-root{ position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
// tokens/semantic/_colors.scss. --color-brand-lighter is declared TWICE there; the second
// wins, so it is green-300 #6d766e, not green-200.
' --dx-surface:#f3f3f1; --dx-elevated:#fff; --dx-text:#2a332a; --dx-header-bg:#3b463d;' +
' --dx-header-text:#fff; --dx-muted:#6d766e; --dx-line:#d1d4cd; --dx-accent:#92b195;' +
' --dx-dark:#2a332a; --dx-on-dark:#f3f3f1; --dx-track:#4e594f; --dx-partial:#f0c64a;' +
' --dx-radius:35px;' +
' background:var(--dx-surface); color:var(--dx-text);' +
' font:16px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }' +
' font:19.5312px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }' +
// Same responsive context + same 1200px cap as tab 1 (regions-list.vue). DX-life applies its
// .container to the DETAIL routes too -- RegionDetail.vue:46 and CountryDetail.vue:35 are both
// class="container page-content ..." -- so the dashboard half is capped by the same rule.
// gap = their .stack--3xl (55.349px), the gap between every block on that page.
'.rd-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;' +
' padding:20px; display:flex; flex-direction:column; gap:20px; }' +
'.rd-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:18px; }' +
' padding:55.349px 0 73.6142px; display:flex; flex-direction:column; gap:55.349px;' +
' container-type:inline-size; }' +
'.rd-scroll > *{ box-sizing:border-box; width:calc(90% + 16px); max-width:1200px;' +
' margin-left:auto; margin-right:auto; }' +
'.rd-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:19.5312px; }' +
'.rd-status.rd-failed{ font-weight:600; }' +
// Subject header: the same dark brand band tab 1 puts at the top of each region card, so the
// two tabs read as one element rather than two designs.
'.rd-subject{ flex:0 0 auto; border-radius:24px; overflow:hidden;' +
' background:var(--dx-header-bg); color:var(--dx-header-text); padding:20px 28px; }' +
'.rd-subject h2{ margin:0; font-size:34px; line-height:1.1;' +
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;' +
// ── head: RegionDetail.vue:47-51, a .stack--xs (7.5px) block, centred ──
'.rd-head{ display:flex; flex-direction:column; gap:10px; text-align:center; }' +
'.rd-backlink{ margin:0; }' +
// their .light-link on a dark-on-light page: brand-light, underlined on hover
'.rd-light-link{ appearance:none; background:none; border:0; padding:0; margin:0; cursor:pointer;' +
' font:inherit; font-size:19.5312px; color:#4e594f; text-decoration:none; }' +
'.rd-light-link:hover{ text-decoration:underline; }' +
'.rd-light-link:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; border-radius:4px; }' +
// h1: _globals.scss clamp(--font-size-3xl, 7vw, --font-size-5xl) = clamp(47.6837px,_,74.5058px),
// heading family, line-height 1. cqw not vw -- see tab 1 for why.
'.rd-title{ margin:0; font-size:clamp(47.6837px, 7cqw, 74.5058px); line-height:1;' +
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; font-stretch:condensed; text-transform:uppercase; }' +
// their .color-brand-lighter summary under the h1
'.rd-summary{ margin:0; color:var(--dx-muted); font-size:19.5312px; }' +
'.rd-prompt{ margin:0; color:var(--dx-muted); font-size:15.625px; }' +
// ── .card: blocks/_cards.scss:9-21. radius --border-radius-xlg (35px);
// padding clamp(--spacing-lg, 6vw, --spacing-2xl) clamp(--spacing-lg, 6vw, --spacing-3xl).
'.rd-card{ position:relative; box-sizing:border-box; border-radius:var(--dx-radius);' +
' background:var(--dx-elevated); color:var(--dx-text);' +
' padding:clamp(23.5264px, 6cqw, 41.6158px) clamp(23.5264px, 6cqw, 55.349px); }' +
// .card[padding-small] -> --spacing-lg flat
'.rd-card--small{ padding:23.5264px; }' +
// .card[data-variant="secondary"] -> surface-brand-dark on text-on-brand-light
'.rd-card--secondary{ background:var(--dx-dark); color:var(--dx-on-dark); }' +
// ── .region-stats + .region-stat: RegionDetail.vue's scoped block ──
'.rd-stats{ display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));' +
' gap:17.689px; }' +
'.rd-stat{ text-align:center; }' +
'.rd-stat__value[data-len="long"]{ font-size:38.147px; }' +
'.rd-stat__value[data-len="xlong"]{ font-size:30.5176px; }' +
'.rd-stat__value{ margin:0; font-size:47.6837px; line-height:1;' +
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; font-stretch:condensed; }' +
'.rd-subject p{ margin:6px 0 0; font-size:16px; opacity:0.85; }' +
// Stat tiles.
'.rd-tiles{ flex:0 0 auto; display:grid; gap:12px;' +
' grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); }' +
'.rd-tile{ background:var(--dx-elevated); border-radius:18px; padding:18px 22px;' +
' border:1px solid var(--dx-line); }' +
'.rd-tile__value{ font-size:28px; font-weight:600; line-height:1.15; }' +
'.rd-tile__label{ margin-top:4px; font-size:14px; color:var(--dx-muted); }' +
// Chart card.
'.rd-chart{ flex:0 0 auto; background:var(--dx-elevated); border-radius:18px;' +
' border:1px solid var(--dx-line); padding:18px 22px; }' +
'.rd-hint{ flex:0 0 auto; color:var(--dx-muted); font-size:14px; }',
'.rd-stat__label{ margin:7.5188px 0 0; font-size:15.625px; color:var(--dx-muted); }' +
'.rd-chart{ padding:23.5264px clamp(17.689px, 4cqw, 31.2901px); }' +
// RegionDetail wraps the note and the map in a .stack--sm (13.3px). The note is their
// .text-center .color-primary .font-weight-bold -- --color-brand-primary at weight 700.
'.rd-mapblock{ display:flex; flex-direction:column; gap:13.3px; }' +
'.rd-mapnote{ margin:0; text-align:center; color:#92b195; font-weight:700; font-size:19.5312px; }' +
// .rounded-xlg on their <CountryMap>; the child takes border-radius:inherit from here.
'.rd-mapframe{ border-radius:var(--dx-radius); overflow:hidden; }' +
// ── .switcher[data-width="xl"]: composition/_switcher.scss. --switcher-width 70rem = 700px,
// gap --spacing-2xl (41.6158px). flex-basis calc((700px - 100%) * 999) is every-layout's
// switch: below 700px of container the basis goes hugely positive and each card takes a
// full row; above it, negative, so the three share the row. Ported as-is -- this IS their
// responsive rule for these cards, and it needs no media query.
'.rd-switcher{ display:flex; flex-wrap:wrap; gap:41.6158px; }' +
// min-width:0 is REQUIRED for their basis to govern. A flex item's automatic minimum size is
// its MIN-CONTENT width, and MEASURED: our goal cards' min-content is ~490px (the h2's
// longest word plus 2x55.35px padding), so three of them could not share 1200px and the
// third wrapped to its own row -- 579/579/1200 instead of their 372/372/372. DX-life needs
// no such reset because Bebas Neue is far narrower than any fallback we can load.
'.rd-switcher > *{ flex-grow:1; flex-basis:calc((700px - 100%) * 999); min-width:0; }' +
// ★ A CONSEQUENCE OF THE FONT SUBSTITUTION, not a departure from their design.
// Their h2 is clamp(--font-size-2xl, 6vw, --font-size-4xl) and resolves to 59.6046px at
// 1440 -- verified live. In Bebas that fits their 372.25px card (261.55px of inner width).
// MEASURED in our stand-in face: 'Engagement' is 380.1px at 59.6046px, 304.1px at 47.6837px,
// 265.3px at 41.6px -- all overflowing -- and 243.2px at 38.147px, which fits. 38.147px is
// --font-size-2xl, the FLOOR OF THEIR OWN CLAMP: the largest step on their type scale our
// substituted face can render in their card. Pinning it there keeps THEIR LAYOUT (three
// cards across, heading on two lines) which is what the clone is actually for. The table
// heading keeps the full clamp -- it has 1200px and no such constraint.
'.rd-goal-card .rd-h2{ font-size:38.147px; }' +
// .stack--lg (23.5264px) inside each card, centred; .region-goal pins to the bottom
// (margin-top:auto) so three cards of different heights still align their goal lines.
'.rd-goal-card{ display:flex; flex-direction:column; gap:23.5264px; text-align:center; }' +
// h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl) = clamp(38.147px,_,59.6046px)
'.rd-h2{ margin:0; font-size:clamp(38.147px, 6cqw, 59.6046px); line-height:1;' +
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; font-stretch:condensed; }' +
'.rd-h2--start{ text-align:start; }' +
// .font-size-4xl .font-weight-medium / .font-size-lg
// Their .font-size-4xl (59.6046px) verified live. Same substitution consequence as the h2:
// in our wider stand-in face 'Adoption Progress' 21 / 2,107 breaks after the slash inside a
// 372px card, which reads as two numbers. Stepped to --font-size-3xl (the next rung down on
// THEIR scale) and pinned nowrap so a figure can never be split across lines. The stat-card
// values keep the full 47.6837px -- those cards are wider and were measured to fit.
'.rd-big{ margin:0; font-size:59.6046px; font-weight:500; line-height:1.1; }' +
'.rd-goal-card .rd-big{ font-size:47.6837px; white-space:nowrap; }' +
'.rd-big-label{ margin:0; font-size:24.4141px; }' +
'.rd-stack-xs{ display:flex; flex-direction:column; gap:10px; }' +
'.rd-goal{ margin:auto 0 0; font-size:15.625px; }' +
// ── .progress-bar: blocks/_progress-bar.scss. The HEIGHT comes from padding-top, not height
// -- clamp(15px, 6%, 25px) at [data-size="md"] -- so the track scales with its own width.
'.rd-bar{ position:relative; overflow:hidden; width:100%; border-radius:100px;' +
' background:var(--dx-track); border:none; padding-top:clamp(15px, 6%, 25px); }' +
'.rd-bar__slider{ position:absolute; top:0; height:100%; width:0; border-radius:100px;' +
' background:var(--dx-accent); transition:width 1s ease-out; }' +
// .prayer-coverage-segments overrides that padding with a real height (their own scoped rule)
'.rd-bar--segments{ display:flex; height:clamp(15px, 6cqw, 25px); padding-top:0; }' +
'.rd-seg{ height:100%; transition:width 1s ease-out; }' +
'.rd-seg--full{ background:var(--dx-accent); }' +
'.rd-seg--partial{ background:var(--dx-partial); }' +
'.rd-seg--none{ background:var(--dx-track); }' +
'.rd-legend{ display:grid; justify-content:center; gap:3.5px; font-size:15.625px;' +
' font-weight:600; text-align:center; }' +
'.rd-legend span{ display:inline-flex; align-items:center; justify-content:center; gap:4px; }' +
'.rd-key{ display:inline-block; flex:0 0 auto; width:7.5px; height:7.5px; border-radius:100px; }' +
'.rd-key--full{ background:var(--dx-accent); }' +
'.rd-key--partial{ background:var(--dx-partial); }' +
'.rd-key--none{ background:var(--dx-track); }' +
// ── the breakdown table: RegionDetail.vue's scoped block. min-width 560px inside an
// overflow-x:auto wrapper is how they keep five columns readable on a phone instead of
// crushing them -- the table scrolls sideways, the page does not.
'.rd-section{ display:flex; flex-direction:column; gap:17.689px; }' +
'.rd-table-scroll{ overflow-x:auto; }' +
'.rd-table{ width:100%; min-width:560px; border-collapse:collapse; font-size:19.5312px; }' +
'.rd-table th, .rd-table td{ padding:7.5188px 13.3px; text-align:start; }' +
'.rd-table th:not(:first-child), .rd-table td:not(:first-child){ text-align:end; }' +
'.rd-table th{ background:var(--dx-muted); color:var(--dx-on-dark); font-weight:500; }' +
'.rd-tr--selected{ background:#e8ece7; font-weight:600; }' +
'.rd-row-link{ appearance:none; background:none; border:0; padding:0; margin:0; cursor:pointer;' +
' font:inherit; color:var(--dx-text); text-align:start; }' +
'.rd-row-link:hover{ text-decoration:underline; }' +
'.rd-row-link:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; border-radius:4px; }',
'region-dashboards'
)
</script>
@ -286,31 +644,144 @@ useShadowStyles(
Region data could not be loaded. This is a connection problem, not a filter.
</div>
<!-- ★ ONE TEMPLATE, ONE MAP INSTANCE — not two branches each owning a <RegionCountryMap>.
MEASURED: with a branch per subject kind, switching region -> country UNMOUNTED one map
and MOUNTED another, and the new one initialised into a container that had no size yet.
It reported a 1200x780 canvas and drew NOTHING: layers [], m.loaded() false, a blank
white card. Every assertion I had passed, because "a canvas exists at size" is not the
same property as "the map drew something".
So the map is mounted ONCE here and re-framed by prop (applySubject), and the blocks
that genuinely differ between their two pages are the ones that come and go. DOM order
is chosen so each page reads in ITS OWN sequence with no CSS reordering:
region/all head · stats · map · chart · goals · table (RegionDetail.vue)
country head · map · people-group list (CountryDetail.vue) -->
<template v-else>
<div class="rd-subject">
<h2>{{ subject.title }}</h2>
<p>{{ subject.context }}</p>
<!-- RegionDetail.vue:47-51 / CountryDetail.vue:37-45 — back-link, title, summary -->
<div class="rd-head">
<p v-if="!isAll" class="rd-backlink">
<button type="button" class="rd-light-link" @click="clearSelection">&larr; All Regions</button>
</p>
<h1 class="rd-title">{{ subject.title }}</h1>
<p v-if="subject.kind === 'country'" class="rd-summary">
{{ formatNumber(subject.stats.count) }} People Groups &middot;
<button
type="button"
class="rd-light-link"
@click="selectFromTable({ kind: 'region', key: selectedRegion?.slug })"
>{{ subject.context }}</button>
</p>
<p v-else class="rd-summary">{{ summaryLine }}</p>
<p v-if="isAll" class="rd-prompt">
Pick a region or a country on the Regions List tab to narrow this dashboard.
</p>
</div>
<div class="rd-tiles">
<div v-for="t in tiles" :key="t.label" class="rd-tile">
<div class="rd-tile__value">{{ t.value }}</div>
<div class="rd-tile__label">{{ t.label }}</div>
<!-- RegionDetail.vue:53-70 — four stat cards. CountryDetail has none. -->
<div v-if="subject.kind !== 'country'" class="rd-stats">
<div v-for="st in stats" :key="st.label" class="rd-card rd-card--small rd-stat">
<p class="rd-stat__value" :data-len="lenBucket(st.value)">{{ st.value }}</p>
<p class="rd-stat__label">{{ st.label }}</p>
</div>
</div>
<div class="rd-chart">
<BarChart
:series="chartRows"
:title="chartTitle"
unit="people groups"
color="#92b195"
/>
<!-- RegionDetail.vue:72-86 and CountryDetail.vue:47-61 — a .stack--sm of the note and the
map. color-by-prayer is theirs on the REGION page only; the country page passes it
not at all, which is what makes its pins read in-country vs surrounding. -->
<div class="rd-mapblock">
<p class="rd-mapnote">{{ mapNote }}</p>
<div class="rd-mapframe">
<RegionCountryMap
:country-codes="focusCodes"
:points="points"
:bounds="mapBounds"
:center="mapCenter"
:zoom="mapZoom"
:color-by-prayer="subject.kind !== 'country'"
/>
</div>
</div>
<p v-if="subject.kind === 'all'" class="rd-hint">
Choose a region or a country on the Regions List tab to focus this dashboard.
</p>
<!-- Ours, not theirs, and kept: the one view that RANKS the subject's parts. -->
<div v-if="subject.kind !== 'country'" class="rd-card rd-chart">
<BarChart :series="chartRows" :title="chartTitle" unit="people groups" color="#92b195" />
</div>
<!-- RegionDetail.vue:88-141 — the three DOXA goal cards in their .switcher -->
<div v-if="subject.kind !== 'country'" class="rd-switcher">
<div class="rd-card rd-card--secondary rd-goal-card">
<h2 class="rd-h2">Prayer Coverage</h2>
<div>
<p class="rd-big">{{ formatNumber(subject.stats.peopleCommitted) }}</p>
<p class="rd-big-label">People committed to praying</p>
</div>
<div class="rd-stack-xs">
<div class="rd-bar rd-bar--segments" aria-label="Prayer coverage progress">
<div class="rd-seg rd-seg--full" :style="{ width: percentOfSubject(subject.stats.withFullPrayer) + '%' }" />
<div class="rd-seg rd-seg--partial" :style="{ width: percentOfSubject(partialPrayerCount) + '%' }" />
<div class="rd-seg rd-seg--none" :style="{ width: percentOfSubject(noPrayerCount) + '%' }" />
</div>
<div class="rd-legend">
<span><i class="rd-key rd-key--full" />100+ People Praying: {{ formatNumber(subject.stats.withFullPrayer) }}</span>
<span><i class="rd-key rd-key--partial" />1+ People Praying: {{ formatNumber(partialPrayerCount) }}</span>
<span><i class="rd-key rd-key--none" />No One Praying: {{ formatNumber(noPrayerCount) }}</span>
</div>
</div>
<p class="rd-goal"><strong>Goal:</strong> Mobilize 100+ people praying daily for each DOXA people group</p>
</div>
<div class="rd-card rd-goal-card">
<h2 class="rd-h2">Adoption Progress</h2>
<div>
<p class="rd-big">{{ formatNumber(subject.stats.adopted) }} / {{ formatNumber(subject.stats.count) }}</p>
<p class="rd-big-label">people groups adopted</p>
</div>
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: adoptedPct + '%' }" /></div>
<p class="rd-goal"><strong>Goal:</strong> Every unengaged people group adopted by a church committed to prayer, giving, and sending</p>
</div>
<div class="rd-card rd-goal-card">
<h2 class="rd-h2">Engagement Progress</h2>
<div>
<p class="rd-big">{{ formatNumber(subject.stats.engaged) }} / {{ formatNumber(subject.stats.count) }}</p>
<p class="rd-big-label">people groups engaged</p>
</div>
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: engagedPct + '%' }" /></div>
<p class="rd-goal"><strong>Goal:</strong> No unengaged people groups by 2033</p>
</div>
</div>
<!-- RegionDetail.vue:144-171 — the five-column breakdown. CountryDetail has no table. -->
<div v-if="subject.kind !== 'country'" class="rd-section">
<h2 class="rd-h2 rd-h2--start">{{ tableTitle }}</h2>
<div class="rd-table-scroll">
<table class="rd-table">
<thead>
<tr>
<th>{{ tableFirstCol }}</th>
<th>People Groups</th>
<th>1+ People Praying</th>
<th>Adopted</th>
<th>Engaged</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableRows" :key="row.key" :class="{ 'rd-tr--selected': row.isSelected }">
<td><button type="button" class="rd-row-link" @click="selectFromTable(row)">{{ row.name }}</button></td>
<td>{{ formatNumber(row.count) }}</td>
<td>{{ formatNumber(row.withPrayer) }}</td>
<td>{{ formatNumber(row.adopted) }}</td>
<td>{{ formatNumber(row.engaged) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CountryDetail.vue:63-69 — UupgsList locked to the country, through our table pattern. -->
<div v-if="subject.kind === 'country' && countryTable" class="rd-section">
<h2 class="rd-h2 rd-h2--start">People groups in {{ subject.title }}</h2>
<DataTable :table="countryTable" :assets-base="assetsBase" />
</div>
</template>
</div>
</div>

View file

@ -29,7 +29,7 @@
* element (same double-emit pattern as people-groups-grid.vue's `groups-filtered`), so a plain
* host page hears it standalone and a Vue-nesting shell hears it as a prop-less DOM event.
*/
import { ref, computed, onMounted, getCurrentInstance } from 'vue'
import { inject, ref, computed, onMounted, getCurrentInstance } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
const props = defineProps({
@ -42,7 +42,20 @@ const props = defineProps({
const emit = defineEmits(['region-selected', 'country-selected'])
// profile-config JSON can override prayBaseUrl / languageCode (single string attr).
// ★ THE CONFIG ARRIVES BY INJECT, NOT AS A PROP (measured 2026-09-23).
// The custom element this bundle registers IS ProfileLoader (index.js:41), which parses the
// profile-config ATTRIBUTE itself and hands the parsed object to its descendants via
// provide('profileConfig', config) (ProfileLoader.vue:195). So a profile component's own
// `profileConfig` STRING PROP is always empty, and every value read off it silently fell back
// to a default: prayBaseUrl, languageCode, instanceId and assetsBase alike. Nothing looked
// broken because the defaults happened to be right -- until assetsBase mattered, and the
// vendored Tabulator 404'd twice with no error while the table just never drew.
// Inject first, fall back to the prop so a direct <regions-dashboard profile-config='...'>
// outside ProfileLoader still works.
const injectedCfg = inject('profileConfig', null)
const cfg = computed(() => {
const inj = injectedCfg && (injectedCfg.value !== undefined ? injectedCfg.value : injectedCfg)
if (inj && typeof inj === 'object') return inj
if (!props.profileConfig) return {}
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})
@ -226,19 +239,41 @@ useShadowStyles(
' --dx-header-text:#fff; --dx-link:#4e594f; --dx-muted:#6d766e; --dx-line:#d1d4cd;' +
' --dx-radius:35px;' +
' background:var(--dx-surface); color:var(--dx-text);' +
' font:19.5px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif; }' +
' font:19.5312px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif; }' +
// container-type:inline-size makes this pane the query container for the cqw units below.
// DX-life sizes those with 4vw (the VIEWPORT), which is right for a full-page route and wrong
// for us: this element is also one pane of the regions-mfe composite, where the viewport is not
// the width available. cqw honours their DECISION (scale with the width you actually have)
// rather than transcribing a unit that only works in their layout. Same primitive already used
// in research-dashboard.vue:284. Safe here because nothing in this element is position:fixed --
// inline-size containment is what made a fixed scrim pane-relative in check:scrim-viewport.
// Side padding is deliberately 0: the .container formula below supplies the gutter, as theirs does.
'.rl-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;' +
' padding:20px; display:flex; flex-direction:column; gap:23.5px; }' +
'.rl-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:19.5px; }' +
' padding:20px 0; display:flex; flex-direction:column; gap:23.5264px;' +
' container-type:inline-size; }' +
// ★ THE DESKTOP MAX-WIDTH, ported verbatim from DX-life's .container
// (app/assets/styles/composition/_container.scss), which BOTH /regions and /regions/<slug> use:
// --container-max-width:1200px; --screen-min-width:320px; --min-margin:8px;
// width: calc(90% + (10/100 * 320px) - (2 * 8px)) -> calc(90% + 16px)
// max-width: 1200px; margin-left/right: auto
// So it is FLUID at 90%+16px until the viewport reaches 1315.625px, then pinned at 1200px centred.
// Our cards previously stretched to the full pane at every width -- there was no cap at all.
// box-sizing:border-box so .rl-status's own 28px padding stays inside the cap.
'.rl-scroll > *{ box-sizing:border-box; width:calc(90% + 16px); max-width:1200px;' +
' margin-left:auto; margin-right:auto; }' +
'.rl-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:19.5312px; }' +
'.rl-status.rl-failed{ font-weight:600; }' +
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) resolved
// at this element's width (17.7px / 41.6px), gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:7.5px; padding:17.7px 41.6px;' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
// is KEPT as a clamp (17.689px / 41.6158px are the resolved ends), not frozen at its desktop maximum.
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:7.5188px;' +
' padding:17.689px clamp(17.689px, 4cqw, 41.6158px);' +
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) resolved 47.7px,
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(30.5176px,_,47.6837px).
// A phone gets the 30.5176px FLOOR, as theirs does; it only reaches 47.6837px past ~1192px of width.
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
// face itself carries the boldness), all-caps to match the source screenshot exactly.
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
@ -252,7 +287,7 @@ useShadowStyles(
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
// exactly rather than compounding extra "boldness" the source doesn't have.
'.region-card__title{ margin:0; font-size:47.7px; line-height:1;' +
'.region-card__title{ margin:0; font-size:clamp(30.5176px, 4cqw, 47.6837px); line-height:1;' +
' font-family:"Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; font-stretch:condensed; letter-spacing:normal; }' +
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:left;' +
@ -265,37 +300,38 @@ useShadowStyles(
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg); }' +
// .region-card__summary: --font-size-md (19.5px), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:19.5px; opacity:0.85; margin:0; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> 23.5px / 41.6px / 31.3px
'.region-card__body{ padding:23.5px 41.6px 31.3px; }' +
// .region-card__summary: --font-size-md (19.5312px), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:19.5312px; opacity:0.85; margin:0; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> 23.5264px / clamp / 31.2901px
'.region-card__body{ padding:23.5264px clamp(17.689px, 4cqw, 41.6158px) 31.2901px; }' +
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
// (10px / 31.3px). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// (10px / 31.2901px). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
// bullet has somewhere to sit without stealing width from the text column.
'.countries-grid{ list-style:none; margin:0; padding:0 0 0 35.2px; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px 31.3px; }' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px 31.2901px; }' +
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
' text-align:left; cursor:pointer; color:var(--dx-text); font:inherit; font-size:19.5px;' +
' text-align:left; cursor:pointer; color:var(--dx-text); font:inherit; font-size:19.5312px;' +
' font-weight:500; width:100%; position:relative; }' +
'.country-link::before{ content:""; position:absolute; left:-16px; top:0.6em; width:6px; height:6px;' +
' border-radius:50%; background:#92b195; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }' +
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (23.5px),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~19.5px at
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (23.5264px),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~19.5312px at
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:23.5px; padding:0 41.6px 23.5px; }' +
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:23.5264px;' +
' padding:0 clamp(17.689px, 4cqw, 41.6158px) 23.5264px; }' +
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
' cursor:pointer; font:inherit; font-size:19.5px; font-weight:500; color:var(--dx-link);' +
' cursor:pointer; font:inherit; font-size:19.5312px; font-weight:500; color:var(--dx-link);' +
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',

View file

@ -0,0 +1,306 @@
<script setup>
/**
* RegionCountryMap.vue — DX-life's CountryMap, rebuilt on the dx-maps map library.
*
* @description The map RegionDetail.vue:72-86 and CountryDetail.vue:47-61 both render: the focus
* countries filled and outlined, every people group plotted, the ones INSIDE the focus coloured
* and the surrounding ones muted so a viewer sees the area's groups in their neighbourhood.
*
* WHAT IS CLONED AND WHAT IS OURS. The LAYERS are DX-life's, expression for expression, from
* root-dx-life/doxa-life/app/components/public/CountryMap.vue:186-240 -- the country-boundaries
* fill at 0.12 opacity, the 2px outline, the worldview filter, the prayer/inCountry colour cases,
* the circle-sort-key that draws better-covered pins on top, the zoom-interpolated radius, the
* 0.75 opacity on neighbours. Their colours too (CountryMap.vue:64-71).
*
* The MACHINERY is ours. Theirs does `(await import('mapbox-gl')).default` and `new mapboxgl.Map`
* directly; that is a Nuxt page component with a document of its own. We are a custom element in a
* shadow root, where three things break and useMapInstance already solves all three: Mapbox's
* `container: 'id'` cannot cross a shadow boundary, its CSS <link> loads async and a map that
* initialises first gets a 0-height canvas, and the wordmark duplicates once per instance on a
* page composing several maps. So this takes the engine, the CSS timing, the resize and the token
* from @map/composables/useMapInstance.js and supplies only the layers.
*
* POINTS COME FROM THE PARENT, not a second fetch. Theirs re-fetches the whole people-groups list
* inside the map component (CountryMap.vue:91-93). region-dashboards.vue has already fetched every
* record to build its rollup, so passing them down keeps ONE request per element and guarantees
* the map and the figures above it describe the same data.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount, inject } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
const props = defineProps({
// ISO-3 codes to highlight, e.g. ['IND'] for a country or every code in a region.
countryCodes: { type: Array, default: () => [] },
// Every people group we know about: { name, slug, code, lng, lat, committed, country, population }.
points: { type: Array, default: () => [] },
// Colour the focus pins by prayer coverage and show the key (their `color-by-prayer`).
colorByPrayer: { type: Boolean, default: false },
center: { type: Array, default: () => [20, 10] },
zoom: { type: Number, default: 2 },
// [[west, south], [east, north]] — takes precedence over center/zoom, as theirs does.
bounds: { type: Array, default: null },
})
// The token ladder: ProfileLoader provides it (the same seam every map bundle uses). A nested
// element has no leaf page of its own, which is what check:nested-token exists to police.
const mapboxToken = inject('mapboxToken', ref(''))
const tokenValue = computed(() => (typeof mapboxToken === 'string' ? mapboxToken : mapboxToken?.value) || '')
// CountryMap.vue:64-71, resolved. brand = --color-brand (#3b463d) fill + outline; inCountry =
// green-100, brighter than the outline so pins read against the fill; muted = --color-brand-lighter;
// the three prayer colours are the Prayer Coverage card's own; outside = --color-brand-secondary-dark,
// warm grey, so neighbours do not compete with the dark "no one praying" pins.
const C = {
brand: '#3b463d',
muted: '#6d766e',
inCountry: '#73A17F',
prayerFull: '#92b195',
prayerPartial: '#f0c64a',
prayerNone: '#4e594f',
outside: '#a59d92',
}
const FULL_PRAYER_COVERAGE_COUNT = 100
const mapContainer = ref(null)
const mapFailed = ref(false)
const geojson = computed(() => {
const focus = new Set(props.countryCodes)
return {
type: 'FeatureCollection',
features: props.points
.filter((p) => Number.isFinite(p.lng) && Number.isFinite(p.lat))
.map((p) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
// JSON primitives only — Mapbox feature properties cannot hold nested objects.
properties: {
name: p.name || '', slug: p.slug || '', country: p.country || '',
inCountry: focus.has(p.code), committed: Number(p.committed) || 0,
population: Number(p.population) || 0,
},
})),
}
})
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
// A STRING, not the ref -- useMapInstance calls accessToken.startsWith('pk.') directly, so a
// ref reaches it as an object and the map dies with "t.startsWith is not a function". Every
// other bundle passes `.value` here (doxa-simple-map.vue:485); measured the hard way.
// An empty value is fine: the composable then falls through to window.MAP_TOKEN, which is the
// rung the generated leaf page sets -- that IS the token ladder.
accessToken: tokenValue.value,
style: 'mapbox://styles/mapbox/light-v11', // theirs, CountryMap.vue:174
center: props.center,
zoom: props.zoom,
maxZoom: 12,
// The map sits inside a scrolling dashboard, so the gray "scroll past me" strips would read as
// part of the page rather than as map chrome.
showEdgeBorders: false,
})
const SRC_PINS = 'rcm-people-groups'
const SRC_COUNTRIES = 'rcm-country-boundaries'
function countryFilter() {
// CountryMap.vue:187-191. The country-boundaries tileset carries one feature PER WORLDVIEW, so
// without this the highlight is drawn two or three times over.
return ['all',
['in', ['get', 'iso_3166_1_alpha_3'], ['literal', props.countryCodes]],
['any', ['==', ['get', 'worldview'], 'all'], ['in', 'US', ['get', 'worldview']]]]
}
// CountryMap.vue:218-223 verbatim. Split out of addLayers() because the LAYER is created once
// and the MODE changes underneath it: RegionDetail passes color-by-prayer, CountryDetail does
// not, and this element now keeps ONE map across both. Creating the layer with the region's
// expression and never updating it left a country page painting prayer colours -- a yellow
// "1+ praying" pin sitting on a page whose whole point is in-country vs surrounding. Nothing
// numeric caught it; it was visible in a screenshot and invisible to every assertion, because
// the pins WERE rendered and the legend WAS correctly absent.
function pinExpressions() {
const outsideFocus = ['!', ['get', 'inCountry']]
const full = ['>=', ['get', 'committed'], FULL_PRAYER_COVERAGE_COUNT]
const some = ['>', ['get', 'committed'], 0]
return {
color: props.colorByPrayer
? ['case', outsideFocus, C.outside, full, C.prayerFull, some, C.prayerPartial, C.prayerNone]
: ['case', ['get', 'inCountry'], C.inCountry, C.muted],
sortKey: props.colorByPrayer
? ['case', outsideFocus, 0, full, 3, some, 2, 1]
: ['case', ['get', 'inCountry'], 1, 0],
}
}
function addLayers() {
const m = map.value
if (!m) return
const { color: pinColor, sortKey: pinSortKey } = pinExpressions()
if (!m.getSource(SRC_COUNTRIES)) {
m.addSource(SRC_COUNTRIES, { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
m.addLayer({
id: 'rcm-focus-fill', type: 'fill', source: SRC_COUNTRIES,
'source-layer': 'country_boundaries', filter: countryFilter(),
paint: { 'fill-color': C.brand, 'fill-opacity': 0.12 },
})
m.addLayer({
id: 'rcm-focus-outline', type: 'line', source: SRC_COUNTRIES,
'source-layer': 'country_boundaries', filter: countryFilter(),
paint: { 'line-color': C.brand, 'line-width': 2 },
})
}
if (!m.getSource(SRC_PINS)) {
m.addSource(SRC_PINS, { type: 'geojson', data: geojson.value })
m.addLayer({
id: 'rcm-pins', type: 'circle', source: SRC_PINS,
layout: { 'circle-sort-key': pinSortKey },
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 6.5],
'circle-color': pinColor,
'circle-opacity': ['case', ['get', 'inCountry'], 1, 0.75],
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff',
},
})
m.on('click', 'rcm-pins', onPinClick)
m.on('mouseenter', 'rcm-pins', () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', 'rcm-pins', () => { m.getCanvas().style.cursor = '' })
}
}
// Their popup (CountryMap.vue:130-162) shows name, country, population and a profile link.
let popup = null
function onPinClick(e) {
const f = e.features && e.features[0]
const g = window.mapboxgl
if (!f || !g) return
const p = f.properties || {}
const pop = Number(p.population) || 0
const html = '<div style="font:14px/1.45 Poppins,sans-serif;color:#2a332a">' +
'<div style="font-weight:600;margin-bottom:2px">' + esc(p.name) + '</div>' +
(p.country ? '<div style="color:#6d766e">' + esc(p.country) + '</div>' : '') +
(pop ? '<div style="color:#6d766e">Population ' + pop.toLocaleString('en-US') + '</div>' : '') +
'<div style="color:#6d766e">' + (Number(p.committed) || 0).toLocaleString('en-US') + ' praying</div>' +
'</div>'
if (popup) popup.remove()
popup = new g.Popup({ closeButton: true, maxWidth: '264px', offset: 12 })
.setLngLat(f.geometry.coordinates).setHTML(html).addTo(map.value)
}
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// Re-frame and re-source on a new selection instead of tearing the map down: a rebuilt map
// re-downloads tiles and flashes, and the subject changes on every tab-1 click.
function applySubject() {
const m = map.value
// Deliberately NOT gated on isMapReady: that flag tracks useMapInstance's own view of the
// load, and gating on it is what let a re-frame land before the layers existed. getSource /
// getLayer answer the only question that matters here.
if (!m) return
const src = m.getSource(SRC_PINS)
if (src) src.setData(geojson.value)
// Re-apply the MODE, not just the data — see pinExpressions().
if (m.getLayer('rcm-pins')) {
const { color, sortKey } = pinExpressions()
m.setPaintProperty('rcm-pins', 'circle-color', color)
m.setLayoutProperty('rcm-pins', 'circle-sort-key', sortKey)
}
for (const id of ['rcm-focus-fill', 'rcm-focus-outline']) {
if (m.getLayer(id)) m.setFilter(id, countryFilter())
}
if (props.bounds) m.fitBounds(props.bounds, { padding: 40, maxZoom: 6, duration: 600 })
else m.flyTo({ center: props.center, zoom: props.zoom, duration: 600 })
}
/**
* Run `fn` once the STYLE is actually usable, with no dependence on when we started listening.
*
* ★ WHY NOT `map.once('load', ...)` — measured 2026-09-23, twice, in both directions.
* Whether 'load' has already fired by the time this component gets to subscribe depends on how
* long the style takes, and the answer changed when the element was restructured. Miss the
* event and addLayers() NEVER RUNS: the map is real, it has a 1200x780 GL canvas, it flies to
* the right centre and zoom, and it draws NOTHING. That state passed every check I had, because
* a canvas at the right size is not the same property as a map that drew something.
* So: ask the map whether it is ready (isStyleLoaded) rather than trusting that we saw the
* moment it became ready, and keep asking on every styledata until it says yes. Idempotent --
* addLayers() guards on getSource, so a duplicate call is a no-op.
*/
function whenStyleReady(m, fn) {
let done = false
const run = () => {
if (done) return
done = true
m.off('style.load', run); m.off('load', run)
fn()
}
// 'style.load' is the event that means "the style JSON is parsed, addSource/addLayer are legal".
// isStyleLoaded() is a STRONGER condition -- it also wants the style's sources loaded -- and
// waiting for it is a trap: MEASURED, the country view at zoom 6 never satisfies it on this box,
// because the local Mapbox token is URL-restricted and its high-zoom tiles 403. The region view
// at zoom 2.8 does satisfy it, so the same code drew 934 pins for a region and nothing at all
// for a country -- an environment limit masquerading as a bug in the country branch.
if (m.isStyleLoaded && m.isStyleLoaded()) return run()
m.on('style.load', run)
m.on('load', run)
// Last resort for exactly that case: if the style object already carries layers, the style is
// parsed and our layers can go on regardless of whether any tile ever arrives.
setTimeout(() => { try { if (m.getStyle && m.getStyle() && m.getStyle().layers) run() } catch (_) {} }, 1500)
}
onMounted(async () => {
try {
await initializeMap()
if (!map.value) { mapFailed.value = true; return }
whenStyleReady(map.value, () => { addLayers(); applySubject() })
} catch (e) {
console.error('[RegionCountryMap] init failed:', e)
mapFailed.value = true
}
})
// A CANVAS AT THE RIGHT SIZE IS NOT A MAP. Exposed so a gate can call
// queryRenderedFeatures() and assert pins and the country fill were actually DRAWN -- the
// country map rendered fully blank while 'a real GL canvas exists at 1200x780' passed.
// Same seam verify:interop reads for the camera (window.__rdashMap).
watch(isMapReady, (r) => { if (r && typeof window !== 'undefined') window.__rcmMap = map.value })
onBeforeUnmount(() => { if (popup) popup.remove(); try { delete window.__rcmMap } catch (_) {} destroy() })
watch(() => [props.countryCodes, props.bounds, props.center, props.zoom, props.points, props.colorByPrayer], applySubject, { deep: true })
// Their .country-map block, CountryMap.vue's scoped style: 16/9 with a 780px floor, flipping to
// 1/2 and no floor under 768px. Ported as an aspect-ratio pair; radius:inherit so the card that
// contains it supplies the corner, exactly as theirs does.
useShadowStyles(
'.rcm{ display:block; position:relative; width:100%; min-height:780px; aspect-ratio:16/9;' +
' overflow:hidden; border-radius:inherit; }' +
'@media (max-width:768px){ .rcm{ min-height:0; aspect-ratio:1/2; } }' +
'.rcm__canvas{ position:absolute; inset:0; }' +
'.rcm__note{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center;' +
' text-align:center; padding:24px; color:#6d766e; font-size:15.625px; background:#f3f3f1; }' +
// Their key, overlaid bottom-left above the canvas (CountryMap.vue's .country-map__legend).
'.rcm__legend{ position:absolute; left:10px; bottom:28px; z-index:2; display:flex;' +
' flex-direction:column; gap:4px; padding:8px 10px; border-radius:8px;' +
' background:rgba(255,255,255,0.92); font-size:12.5px; font-weight:600; color:#2a332a; }' +
'.rcm__legend span{ display:inline-flex; align-items:center; gap:6px; }' +
'.rcm__legend i{ width:10px; height:10px; border-radius:100px; flex:0 0 auto; }',
'region-country-map'
)
</script>
<template>
<div class="rcm">
<div ref="mapContainer" class="rcm__canvas" />
<p v-if="mapFailed" class="rcm__note" role="status">
The map could not start. This is a map-token or connection problem, not a filter — the
figures above are unaffected.
</p>
<div v-if="colorByPrayer && !mapFailed" class="rcm__legend">
<span><i :style="{ background: C.prayerFull }" />100+ People Praying</span>
<span><i :style="{ background: C.prayerPartial }" />1+ People Praying</span>
<span><i :style="{ background: C.prayerNone }" />No One Praying</span>
<span><i :style="{ background: C.outside }" />Outside this region</span>
</div>
</div>
</template>

View file

@ -23,7 +23,7 @@
* responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync.
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
*/
import { exactMatches, fuzzyMatches } from '@map/components/search/fuzzy.js'
import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
import { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from './useSelectionBus.js'
import { useDashboardData } from './useDashboardData.js'
@ -82,14 +82,36 @@ export function makeSearchMatcher(bus, accessors) {
const matcher = (row) => test(row, fuzzyMatches)
matcher.exact = (row) => test(row, exactMatches)
matcher.active = () => !!(bus?.searchTerm?.value || '').trim()
matcher.term = () => (bus?.searchTerm?.value || '').trim()
matcher.accessors = accessors
return matcher
}
/** Apply the tiered search to a row set: exact tier first, fuzzy only as the typo rescue. */
/**
* Apply the TIERED search to a row set:
* 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects.
* 2. facet-VALUE resolution (Driver: "Austrian or Canadian... a selection of that country --
* the table under the map shows the result for that country"): the term is resolved by
* whole-value edit distance against each accessor's DISTINCT values, and the rows returned
* are those carrying a resolved value -- byte-identical to picking it as a filter.
* 3. the generic windowed fuzzy rescue, only when nothing resolves.
*/
export function filterRowsBySearch(rows, matcher) {
if (!matcher.active()) return rows
const exact = rows.filter(matcher.exact)
return exact.length ? exact : rows.filter(matcher)
if (exact.length) return exact
const term = matcher.term()
// ONE pool across every accessor's distinct values, so the GLOBAL best match wins (see
// resolveFuzzyValues: ties at the minimal distance stay, everything farther drops --
// 'Canadian' resolves to Canada, not to 47 rows of <=3-edit neighbours across facets).
const distinct = new Set()
for (const acc of matcher.accessors) for (const r of rows) { const v = acc(r); if (v != null && v !== '') distinct.add(String(v)) }
const winners = new Set(resolveFuzzyValues(term, [...distinct], { threshold: 0.4 }))
if (winners.size) {
const resolved = rows.filter((r) => matcher.accessors.some((acc) => { const v = acc(r); return v != null && winners.has(String(v)) }))
if (resolved.length) return resolved
}
return rows.filter(matcher)
}
/**

File diff suppressed because one or more lines are too long

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-v0b+aZ7TBxmgUIBBLdKEm6HzwjY3jAGyOFNsKeKCKY4EIRpiWRTcNLavAitHRt8h" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-v0b+aZ7TBxmgUIBBLdKEm6HzwjY3jAGyOFNsKeKCKY4EIRpiWRTcNLavAitHRt8h" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../big-picture.js" integrity="sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-v0b+aZ7TBxmgUIBBLdKEm6HzwjY3jAGyOFNsKeKCKY4EIRpiWRTcNLavAitHRt8h" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-UTYLRFiNWmKlIlICfa6YLPib+AxYU4q0LRNq8iAzT+NaowJJltzEYEHa49htnFww" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-UTYLRFiNWmKlIlICfa6YLPib+AxYU4q0LRNq8iAzT+NaowJJltzEYEHa49htnFww" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-e0HUXubejRqYxQaZKpTjMvFaWIdWEakh4U+4waJXdPsGWsFRBqMqzq4CrWPxkJ3Q" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-e0HUXubejRqYxQaZKpTjMvFaWIdWEakh4U+4waJXdPsGWsFRBqMqzq4CrWPxkJ3Q" 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

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-YIcYmnktFXizWh4bC8Uwu/SGknvzONjT94ks7Od5brk0C+o+ukJrUTU/qsRTaUjj" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-DSgWMYtNlvvyqIwcrFlZzj0ti1WhYywDDGWr90iUpoFQH8wex8S6ASSBCjkRy5u7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -27,7 +27,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../../doxa-research-map.js" integrity="sha384-YIcYmnktFXizWh4bC8Uwu/SGknvzONjT94ks7Od5brk0C+o+ukJrUTU/qsRTaUjj" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-DSgWMYtNlvvyqIwcrFlZzj0ti1WhYywDDGWr90iUpoFQH8wex8S6ASSBCjkRy5u7" 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

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-8zB7l5N7hIH+3BgOCV1PVu92q/8/19j2K29Ih1VC/0oMZZlcD4JfwaC3M6vTbDWO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-8zB7l5N7hIH+3BgOCV1PVu92q/8/19j2K29Ih1VC/0oMZZlcD4JfwaC3M6vTbDWO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script src="../doxa-simple-map.js" integrity="sha384-8zB7l5N7hIH+3BgOCV1PVu92q/8/19j2K29Ih1VC/0oMZZlcD4JfwaC3M6vTbDWO" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-8zB7l5N7hIH+3BgOCV1PVu92q/8/19j2K29Ih1VC/0oMZZlcD4JfwaC3M6vTbDWO" 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

@ -35,7 +35,7 @@
"bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js",
"output": "doxa-maps/big-picture/big-picture.js",
"integrity": "sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG",
"integrity": "sha384-v0b+aZ7TBxmgUIBBLdKEm6HzwjY3jAGyOFNsKeKCKY4EIRpiWRTcNLavAitHRt8h",
"elements": [
"bp-big-picture",
"bp-research-header",
@ -57,7 +57,7 @@
"scriptUrl": "./big-picture/big-picture.js",
"embed": {
"iframe": "<iframe src=\"./big-picture/big-picture/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=\"./big-picture/big-picture.js\" integrity=\"sha384-LcTqxsi0hgbx7m3gsJQwpYSZkCHP1KDLkwUmBknrOBo6vvPxS10MhozDcVCdCwMG\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-v0b+aZ7TBxmgUIBBLdKEm6HzwjY3jAGyOFNsKeKCKY4EIRpiWRTcNLavAitHRt8h\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
},
"instances": [
{
@ -154,7 +154,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "doxa-maps/country-posters/country-posters.js",
"integrity": "sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55",
"integrity": "sha384-UTYLRFiNWmKlIlICfa6YLPib+AxYU4q0LRNq8iAzT+NaowJJltzEYEHa49htnFww",
"elements": [
"country-posters-map"
],
@ -170,7 +170,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-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-posters/country-posters.js\" integrity=\"sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-UTYLRFiNWmKlIlICfa6YLPib+AxYU4q0LRNq8iAzT+NaowJJltzEYEHa49htnFww\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -184,7 +184,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/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-posters/country-posters.js\" integrity=\"sha384-Hz695w/9J1maAa0H9nynW2C6KvE6ltlaaTV9rtkNLDEHnxuI53dXZtiLL7JtVF55\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-UTYLRFiNWmKlIlICfa6YLPib+AxYU4q0LRNq8iAzT+NaowJJltzEYEHa49htnFww\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -193,7 +193,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X",
"integrity": "sha384-e0HUXubejRqYxQaZKpTjMvFaWIdWEakh4U+4waJXdPsGWsFRBqMqzq4CrWPxkJ3Q",
"elements": [
"dashboard-plus-grid"
],
@ -213,7 +213,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/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=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-RwMGtLXZFtctic8xn5T2NZgovHMoiebxlDlXF6ywWqYIaei+Dr6Ujt+Zuf0HoS7X\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-e0HUXubejRqYxQaZKpTjMvFaWIdWEakh4U+4waJXdPsGWsFRBqMqzq4CrWPxkJ3Q\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -331,7 +331,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-YIcYmnktFXizWh4bC8Uwu/SGknvzONjT94ks7Od5brk0C+o+ukJrUTU/qsRTaUjj",
"integrity": "sha384-DSgWMYtNlvvyqIwcrFlZzj0ti1WhYywDDGWr90iUpoFQH8wex8S6ASSBCjkRy5u7",
"elements": [
"doxa-research-map"
],
@ -347,7 +347,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-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=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-YIcYmnktFXizWh4bC8Uwu/SGknvzONjT94ks7Od5brk0C+o+ukJrUTU/qsRTaUjj\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-DSgWMYtNlvvyqIwcrFlZzj0ti1WhYywDDGWr90iUpoFQH8wex8S6ASSBCjkRy5u7\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -373,7 +373,7 @@
"bundle": "doxa-simple-map",
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"output": "doxa-maps/doxa-simple-map/doxa-simple-map.js",
"integrity": "sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+",
"integrity": "sha384-8zB7l5N7hIH+3BgOCV1PVu92q/8/19j2K29Ih1VC/0oMZZlcD4JfwaC3M6vTbDWO",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -390,7 +390,7 @@
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-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=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-nj5ld5CCWbfFoRiOSAdTKuZqK97b9HnROryFnZI8bDC8/ASzsLeqQdK5dI71lsq+\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-8zB7l5N7hIH+3BgOCV1PVu92q/8/19j2K29Ih1VC/0oMZZlcD4JfwaC3M6vTbDWO\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
},
"instances": [
{
@ -640,7 +640,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE",
"integrity": "sha384-X8Guibb3M+sTLampUzojsKp8BlwTGfscE/ZKkGlwKm15PXhvIdGTwx8S/y3BQgEa",
"elements": [
"my-upg-100-list-map"
],
@ -660,7 +660,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-X8Guibb3M+sTLampUzojsKp8BlwTGfscE/ZKkGlwKm15PXhvIdGTwx8S/y3BQgEa\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -674,7 +674,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-X8Guibb3M+sTLampUzojsKp8BlwTGfscE/ZKkGlwKm15PXhvIdGTwx8S/y3BQgEa\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -688,7 +688,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-X8Guibb3M+sTLampUzojsKp8BlwTGfscE/ZKkGlwKm15PXhvIdGTwx8S/y3BQgEa\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -697,7 +697,7 @@
"bundle": "people-groups-grid",
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"output": "doxa-maps/people-groups-grid/people-groups-grid.js",
"integrity": "sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG",
"integrity": "sha384-wyfAzVfoufR5FaDoo3DSivtV33qOexc7PhDYZkXlYmru5FqZgncuUos3nN0kkMJL",
"elements": [
"people-groups-grid"
],
@ -713,7 +713,7 @@
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"embed": {
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/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=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-wyfAzVfoufR5FaDoo3DSivtV33qOexc7PhDYZkXlYmru5FqZgncuUos3nN0kkMJL\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
},
"instances": [
{
@ -838,7 +838,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan",
"integrity": "sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+",
"elements": [
"regions-dashboard"
],
@ -853,12 +853,12 @@
"description": "The dashboard half of the Driver's 2-tab Regions element: given a region (or a",
"element": "regions-dashboard",
"type": "single",
"isMap": false,
"isMap": true,
"iframeUrl": "./regions-dashboard/region-dashboards/index.html",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" 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>"
}
},
{
@ -867,12 +867,12 @@
"description": "A refactor of DX-life's /regions index page (app/pages/regions/index.vue) into",
"element": "regions-dashboard",
"type": "single",
"isMap": false,
"isMap": true,
"iframeUrl": "./regions-dashboard/regions-list/index.html",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan\" 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-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" 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>"
}
},
{
@ -881,12 +881,12 @@
"description": "The composite the Driver asked for: ONE element carrying the SELECT VIEW switcher",
"element": "regions-dashboard",
"type": "nested-elements-mfe",
"isMap": false,
"isMap": true,
"iframeUrl": "./regions-dashboard/regions-mfe/index.html",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" 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>"
}
}
]
@ -895,7 +895,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "doxa-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-9/hF/6WuV7HVDQuZS1MbAOJCtvh+RsNVEcRoPBTbfTn8SUNYnW2A46OY8EXcznsO",
"integrity": "sha384-38/J/SjWxW5MGCoqx5ttsr2pHXA6uSxV+ydQX+2urmWCSAimbHyW3KinkJA4ZSPf",
"elements": [
"research-dashboard-map"
],
@ -911,7 +911,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/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=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-9/hF/6WuV7HVDQuZS1MbAOJCtvh+RsNVEcRoPBTbfTn8SUNYnW2A46OY8EXcznsO\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-38/J/SjWxW5MGCoqx5ttsr2pHXA6uSxV+ydQX+2urmWCSAimbHyW3KinkJA4ZSPf\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-X8Guibb3M+sTLampUzojsKp8BlwTGfscE/ZKkGlwKm15PXhvIdGTwx8S/y3BQgEa" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-X8Guibb3M+sTLampUzojsKp8BlwTGfscE/ZKkGlwKm15PXhvIdGTwx8S/y3BQgEa" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-KqXR+HLamYnXTwmZ0Cxxz/0OP6mS8hkwu0GPsVsZ2CydWAUtNTl7yNZkQMm0BKPE" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-X8Guibb3M+sTLampUzojsKp8BlwTGfscE/ZKkGlwKm15PXhvIdGTwx8S/y3BQgEa" 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

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-wyfAzVfoufR5FaDoo3DSivtV33qOexc7PhDYZkXlYmru5FqZgncuUos3nN0kkMJL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../people-groups-grid.js" integrity="sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG" crossorigin="anonymous"></script>
<script src="../people-groups-grid.js" integrity="sha384-wyfAzVfoufR5FaDoo3DSivtV33qOexc7PhDYZkXlYmru5FqZgncuUos3nN0kkMJL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-ktuMhVzpB05It1q3/X0mtY6H9bbjBSeamFjKx0BoQu1DuoNXNZBtDNs9itrbcHPG" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-wyfAzVfoufR5FaDoo3DSivtV33qOexc7PhDYZkXlYmru5FqZgncuUos3nN0kkMJL" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>region-dashboards · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +48,7 @@
var BASE_CONFIG = {"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

File diff suppressed because one or more lines are too long

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>regions-list · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -22,11 +25,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +48,7 @@
var BASE_CONFIG = {"profile":"regions-list","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-list","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

View file

@ -5,10 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>regions-mfe · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
@ -33,11 +36,11 @@
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-jSSCXCZ3Z6JEFDmap/W92BrPfKdpjl7pMh/t/MbkN3MxY1cYAjrq3NZsUnvfsxan" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -56,7 +59,7 @@
var BASE_CONFIG = {"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-mfe","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_FREE = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-9/hF/6WuV7HVDQuZS1MbAOJCtvh+RsNVEcRoPBTbfTn8SUNYnW2A46OY8EXcznsO" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-38/J/SjWxW5MGCoqx5ttsr2pHXA6uSxV+ydQX+2urmWCSAimbHyW3KinkJA4ZSPf" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: