# 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:
commit
ad534beb64
49 changed files with 1676 additions and 387 deletions
276
internal/fix-sri.mjs
Normal file
276
internal/fix-sri.mjs
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
30
library/composables/retryableAsyncComponent.js
Normal file
30
library/composables/retryableAsyncComponent.js
Normal 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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">← 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 ·
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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; }',
|
||||
|
|
|
|||
306
plugin-designs/regions-dashboard/src/RegionCountryMap.vue
Normal file
306
plugin-designs/regions-dashboard/src/RegionCountryMap.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
14
public/doxa-maps/big-picture/big-picture.js
generated
14
public/doxa-maps/big-picture/big-picture.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
86
public/doxa-maps/country-posters/country-posters.js
generated
86
public/doxa-maps/country-posters/country-posters.js
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
62
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
62
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
52
public/doxa-maps/manifest.json
generated
52
public/doxa-maps/manifest.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
54
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
54
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue