All People Groups loads fast on a light map; the Vision Card becomes a true funnel from data
ALL PEOPLE GROUPS. The Driver: "not even loading, takes a very long
time", and its DOXA pins were dark on a dark map.
- It downloaded the 7.5 MB people-groups-slim.json (22 columns) to draw
ten fields. It now fetches people-groups-points.json: the same rows in
column order, repeated strings in dictionaries, coordinates at 3
decimals. 0.74 MB, generated deterministically by
library/scripts/people-groups/build-points.mjs and staged into the tree
by generate-tree. It falls back to the old file on an older tree.
- Pins no longer wait for the live prayer API. Every pin draws by GSEC
from the file, and the DOXA overlay recolours when the API answers. The
DOXA count reads "…" until then, not 0.
- Named style 'paper' (Mapbox light-v11 / MapLibre positron) instead of
'ink'. The four categories keep their meaning; yellow and green are a
step darker, and pins get a thin dark ring, so they read on near-white.
- Its words are in all 11 locales (allPeopleGroups.json), with a guarded
useI18n and values passed to vue-i18n so {n} is not blanked.
Measured on a served built tree, 3 fresh runs each, median time to first
rendered pins: 8.3 s before, 5.2 s after, on localhost. This box renders
GL in software, so most of the remaining time is map start-up; the 10x
smaller download matters more on a real connection.
DOXA VISION CARD. The Driver: "the data doesn't make sense … super false
information". The card rotated four typed-in figures with no source.
It is now a proportional funnel, every number computed from data by
library/scripts/people-groups/build-vision-funnel.mjs:
12,379 people groups (IMB rows: one group in one country)
7,341 unreached (IMB LostnessPriority 0-2, < 2% evangelical)
4,754 frontier (LostnessPriority 0, < 0.1% evangelical)
2,107 DOXA groups (the DOXA prayer list, snapshot saved in data/;
1,745 of them frontier)
The first three nest; DOXA groups do not all sit inside frontier, so the
card says how many do. The rules are on the card ("How these are
counted") and in HOW-THE-NUMBERS-ARE-COUNTED.md, which also explains why
the total is not the 16-17 thousand of Joshua Project's count. Material
light, all 11 locales, right-to-left in Arabic, sources and dates shown.
The old fake stats are removed from the instances file, the frontier
page, the manifest, the staging page and the gallery.
CHECKS.
- check:vision-card-numbers recomputes the funnel independently (own CSV
reader, the published rules) and compares with vision-funnel.json and,
with --url, the rendered bars. Negative-tested: a corrupted count exits
1. On the built tree: bars 12,379 / 7,341 / 4,754 / 2,107 and "1,745 of
them are frontier", all matching.
- check:map-embed-cells measures PRODUCTION-MATRIX cells B and C for the
seven gallery maps on a foreign origin; C proves the host's token is
used by handing the host a distinct token and checking every Mapbox
request. 14 of 14 pass.
This commit is contained in:
parent
ad81d16511
commit
33bd7ca387
45 changed files with 11678 additions and 318 deletions
10541
data/people-groups/doxa-groups-snapshot.json
Normal file
10541
data/people-groups/doxa-groups-snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1
data/people-groups/people-groups-points.json
Normal file
1
data/people-groups/people-groups-points.json
Normal file
File diff suppressed because one or more lines are too long
40
data/people-groups/vision-funnel.json
Normal file
40
data/people-groups/vision-funnel.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"sources": {
|
||||
"imb": {
|
||||
"file": "data/people-groups/people_groups.csv",
|
||||
"publisher": "IMB / peoplegroups.org",
|
||||
"captured": "2026-07-16"
|
||||
},
|
||||
"doxa": {
|
||||
"file": "data/people-groups/doxa-groups-snapshot.json",
|
||||
"source": "https://pray.doxa.life/api/people-groups/list",
|
||||
"fetched": "2026-09-25"
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "all",
|
||||
"groups": 12379,
|
||||
"population": 8288563969
|
||||
},
|
||||
{
|
||||
"id": "unreached",
|
||||
"groups": 7341,
|
||||
"population": 4893057899
|
||||
},
|
||||
{
|
||||
"id": "frontier",
|
||||
"groups": 4754,
|
||||
"population": 2813855464
|
||||
},
|
||||
{
|
||||
"id": "doxa",
|
||||
"groups": 2107,
|
||||
"population": 209079159,
|
||||
"frontier": 1745,
|
||||
"unreached": 2082,
|
||||
"matched": 2091,
|
||||
"unmatched": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
152
internal/check-map-embed-cells.mjs
Normal file
152
internal/check-map-embed-cells.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-map-embed-cells — measure the PRODUCTION-MATRIX embed cells B (iframe) and C (script embed)
|
||||
* for each production map, on a genuinely FOREIGN origin: the built tree on 127.0.0.1:<port>, a
|
||||
* stand-in partner page on localhost:<other port>. Starts both servers itself; needs a built tree.
|
||||
*
|
||||
* USAGE: node internal/check-map-embed-cells.mjs [--only bundle/profile,bundle/profile] [--cell B|C]
|
||||
* Graded by EXIT CODE: 0 all measured cells pass · 1 a cell failed · 2 nothing could be measured.
|
||||
*
|
||||
* B · iframe: the partner page iframes the map's own leaf page. PASS = the frame renders real content
|
||||
* (deep element count), and the HOST is untouched: its URL, title and scroll position.
|
||||
* C · script: the partner page pastes the manifest's own embed snippet, with ITS OWN token. PASS = the
|
||||
* element renders, no page error, no failed request to the tree's origin, and every
|
||||
* api.mapbox.com request carries the HOST's token.
|
||||
*
|
||||
* ★ HOW C PROVES THE HOST'S TOKEN IS USED. Handing the partner page the tree's own baked token proves
|
||||
* nothing — a bundle that ignored the host and fell back to its baked key would pass. So the host
|
||||
* passes a DISTINCT, well-formed pk token that exists nowhere in the tree. Every api.mapbox.com
|
||||
* request is intercepted: it must carry that host token (else FAIL: the map used another key), and
|
||||
* is then forwarded with the real token so the map can still paint. A map drawn with zero Mapbox
|
||||
* requests is fine for maps that need none; that is reported, not failed.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
||||
// Default: the seven Research Gallery maps (doxa-maps-spa.meta.json `composes`).
|
||||
const DEFAULT = JSON.parse(readFileSync(join(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json'), 'utf8')).composes
|
||||
const ONLY = (argOf('--only', '') ? argOf('--only', '').split(',') : DEFAULT).map((s) => s.trim()).filter(Boolean)
|
||||
const CELLS = argOf('--cell', '') ? [argOf('--cell', '').toUpperCase()] : ['B', 'C']
|
||||
|
||||
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
|
||||
if (!existsSync(MANIFEST)) { console.log('MAP-EMBED-CELLS: SKIP — no built tree'); process.exit(2) }
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
|
||||
const bakedToken = (() => {
|
||||
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
|
||||
if (!p.iframeUrl) continue
|
||||
const leaf = join(ROOT, 'public', 'doxa-maps', String(p.iframeUrl).replace(/^\.\//, ''))
|
||||
if (!existsSync(leaf)) continue
|
||||
const m = readFileSync(leaf, 'utf8').match(/BAKED_TOKEN\s*=\s*"(pk\.[^"]+)"/)
|
||||
if (m) return m[1]
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
// A well-formed pk token that exists nowhere in the tree (Mapbox GL parses the middle segment).
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
||||
const HOST_TOKEN = 'pk.' + b64({ u: 'partnerhost', a: 'embedprobe' }) + '.hostpartnerprobe'
|
||||
|
||||
const targets = []
|
||||
for (const ref of ONLY) {
|
||||
const [bundle, profile] = ref.split('/')
|
||||
const b = (manifest.bundles || []).find((x) => x.bundle === bundle)
|
||||
const p = b && (b.profiles || []).find((x) => x.profile === profile)
|
||||
if (!b || !p) { targets.push({ ref, missing: true }); continue }
|
||||
const defined = b.elements || []
|
||||
const element = defined.includes(p.element) ? p.element : (defined[0] || p.element)
|
||||
targets.push({ ref, bundle, profile, element, iframeUrl: p.iframeUrl, snippet: (p.embed && p.embed.script) || '' })
|
||||
}
|
||||
|
||||
const freePort = () => new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const port = s.address().port; s.close(() => ok(port)) }) })
|
||||
const TREE_PORT = await freePort()
|
||||
const HOST_PORT = await freePort()
|
||||
const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps`
|
||||
let pageHtml = '<!doctype html><title>host</title><body>nothing</body>'
|
||||
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' })
|
||||
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); r.end(pageHtml) }).listen(HOST_PORT, 'localhost')
|
||||
const cleanup = () => { try { tree.kill() } catch (_) {} try { host.close() } catch (_) {} }
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
if (!(await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false))) {
|
||||
cleanup(); console.log('MAP-EMBED-CELLS: SKIP — could not serve the built tree'); process.exit(2)
|
||||
}
|
||||
const HOST = `http://localhost:${HOST_PORT}/partner.html`
|
||||
|
||||
const deepCount = `(() => { let n = 0, c = 0; const w = (r) => { for (const e of r.querySelectorAll('*')) { n++; if (e.tagName === 'CANVAS') c++; if (e.shadowRoot) w(e.shadowRoot) } }; w(document); return { n, c } })()`
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
const rows = []
|
||||
for (const t of targets) {
|
||||
if (t.missing) { rows.push({ ref: t.ref, cell: '-', ok: false, why: 'not in the built manifest' }); continue }
|
||||
for (const cell of CELLS) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const errors = [], treeFails = [], mapboxTokens = new Set()
|
||||
page.on('pageerror', (e) => errors.push(String(e.message || e).slice(0, 140)))
|
||||
page.on('response', (r) => { if (r.status() >= 400 && r.url().startsWith(`http://127.0.0.1:${TREE_PORT}/`)) treeFails.push(r.status() + ' ' + r.url().replace(`http://127.0.0.1:${TREE_PORT}`, '').slice(0, 90)) })
|
||||
await ctx.route(/^https:\/\/api\.mapbox\.com\//, async (route) => {
|
||||
const u = new URL(route.request().url())
|
||||
const tok = u.searchParams.get('access_token')
|
||||
if (tok) { mapboxTokens.add(tok === HOST_TOKEN ? 'HOST' : tok === bakedToken ? 'BAKED' : 'OTHER'); if (tok === HOST_TOKEN) u.searchParams.set('access_token', bakedToken) }
|
||||
await route.continue({ url: u.toString() })
|
||||
})
|
||||
|
||||
let res
|
||||
if (cell === 'B') {
|
||||
const src = `${TREE}/${String(t.iframeUrl).replace(/^\.\//, '')}`
|
||||
pageHtml = `<!doctype html><html><head><meta charset="utf-8"><title>Partner page</title></head><body style="margin:0;font:16px sans-serif">
|
||||
<h1 style="margin:16px">A partner's own page</h1><iframe id="f" src="${src}" width="100%" height="640" style="border:0"></iframe>
|
||||
<div style="height:1400px;padding:16px">Partner content below the map.</div></body></html>`
|
||||
await page.goto(HOST, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(14000)
|
||||
const frame = page.frames().find((f) => f.url().startsWith(`http://127.0.0.1:${TREE_PORT}/`))
|
||||
const inside = frame ? await frame.evaluate(deepCount).catch(() => ({ n: 0, c: 0 })) : { n: 0, c: 0 }
|
||||
const hostState = await page.evaluate(() => ({ url: location.href, title: document.title, y: Math.round(scrollY) }))
|
||||
const why = []
|
||||
if (!frame) why.push('iframe never loaded the leaf')
|
||||
if (inside.n < 40) why.push('frame rendered almost nothing (' + inside.n + ' elements)')
|
||||
if (hostState.url !== HOST) why.push('host URL changed to ' + hostState.url)
|
||||
if (hostState.title !== 'Partner page') why.push('host title changed to "' + hostState.title + '"')
|
||||
if (hostState.y !== 0) why.push('host scrolled to y=' + hostState.y)
|
||||
if (treeFails.length) why.push('failed tree requests: ' + [...new Set(treeFails)].slice(0, 3).join(' , '))
|
||||
res = { why, detail: inside.n + ' elements, ' + inside.c + ' canvas in frame; host url/title/scroll unchanged' }
|
||||
} else {
|
||||
if (!t.snippet) { rows.push({ ref: t.ref, cell, ok: false, why: 'manifest has no script embed snippet' }); await ctx.close(); continue }
|
||||
// The manifest's own snippet, made absolute to the tree, with the HOST's token.
|
||||
let snip = t.snippet.replace(/src="\.\//g, `src="${TREE}/`).replace(/YOUR_MAPBOX_TOKEN/g, HOST_TOKEN)
|
||||
snip = snip.replace(/("tk"\s*:\s*")[^"]*(")/g, `$1${HOST_TOKEN}$2`)
|
||||
pageHtml = `<!doctype html><html><head><meta charset="utf-8"><title>Partner page</title></head><body style="margin:0;font:16px sans-serif">
|
||||
<h1 style="margin:16px">A partner's own page</h1><div style="position:relative;height:640px">${snip}</div>
|
||||
<div style="height:600px;padding:16px">Partner content below the map.</div></body></html>`
|
||||
await page.goto(HOST, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(14000)
|
||||
const st = await page.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(t.element)}); if (!el) return { present: false }
|
||||
const d = ${deepCount}; return { present: true, defined: !!customElements.get(${JSON.stringify(t.element)}), n: d.n, c: d.c, h: Math.round(el.getBoundingClientRect().height) } })()`)
|
||||
const why = []
|
||||
if (!st.present) why.push('snippet did not put <' + t.element + '> on the page')
|
||||
else {
|
||||
if (!st.defined) why.push('<' + t.element + '> never registered')
|
||||
if (st.n < 40) why.push('rendered almost nothing (' + st.n + ' elements)')
|
||||
if (st.h < 100) why.push('element is ' + st.h + 'px tall')
|
||||
}
|
||||
if (mapboxTokens.has('BAKED') || mapboxTokens.has('OTHER')) why.push('Mapbox requests used a token other than the HOST\'s: ' + [...mapboxTokens].join(', '))
|
||||
if (treeFails.length) why.push('failed tree requests: ' + [...new Set(treeFails)].slice(0, 3).join(' , '))
|
||||
if (errors.length) why.push('page error: ' + [...new Set(errors)].slice(0, 2).join(' / '))
|
||||
res = { why, detail: st.n + ' elements, ' + st.c + ' canvas, ' + st.h + 'px; Mapbox token(s): ' + ([...mapboxTokens].join(',') || 'none requested') }
|
||||
}
|
||||
rows.push({ ref: t.ref, cell, ok: !res.why.length, why: res.why.join(' | '), detail: res.detail })
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
await browser.close(); cleanup()
|
||||
|
||||
for (const r of rows) console.log((r.ok ? 'PASS ' : 'FAIL ') + r.cell + ' ' + r.ref.padEnd(46) + (r.ok ? r.detail : r.why))
|
||||
const bad = rows.filter((r) => !r.ok)
|
||||
console.log(bad.length ? `MAP-EMBED-CELLS: FAIL — ${bad.length} of ${rows.length} cell(s)` : `MAP-EMBED-CELLS: PASS — ${rows.length} cell(s) on a foreign origin`)
|
||||
process.exit(bad.length ? 1 : 0)
|
||||
66
internal/check-vision-card-numbers.mjs
Normal file
66
internal/check-vision-card-numbers.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-vision-card-numbers — the DOXA Vision Card shows the numbers the DATA gives, nothing typed.
|
||||
*
|
||||
* USAGE: node internal/check-vision-card-numbers.mjs (data only, no browser, ~2 s)
|
||||
* node internal/check-vision-card-numbers.mjs --url <card> (also read the RENDERED card)
|
||||
* Exit 0 = every number matches · 1 = a mismatch.
|
||||
*
|
||||
* INDEPENDENT RECOMPUTATION. It does not import build-vision-funnel.mjs: it re-reads the raw CSV with
|
||||
* its own column lookup and re-applies the published rules (HOW-THE-NUMBERS-ARE-COUNTED.md) to the
|
||||
* saved DOXA list, then compares with (1) data/people-groups/vision-funnel.json, which the card
|
||||
* compiles in, and (2) with --url, the numbers actually painted in the card's bars. A typed-in
|
||||
* number, a stale funnel file or a rendering bug each makes it fail.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const text = readFileSync(resolve(ROOT, 'data/people-groups/people_groups.csv'), 'utf8')
|
||||
|
||||
// Own CSV reader: split records on newlines outside quotes, fields on commas outside quotes.
|
||||
const records = []; { let cur = ''; let q = false; for (const ch of text) { if (ch === '"') q = !q; if (ch === '\n' && !q) { records.push(cur); cur = '' } else cur += ch } if (cur.trim()) records.push(cur) }
|
||||
const split = (line) => { const out = []; let f = ''; let q = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (c === '"') { if (q && line[i + 1] === '"') { f += '"'; i++ } else q = !q } else if (c === ',' && !q) { out.push(f); f = '' } else if (c !== '\r') f += c } out.push(f); return out }
|
||||
const head = split(records[0]).map((h) => h.replace(/^/, ''))
|
||||
const col = (n) => head.indexOf(n)
|
||||
const L = col('LostnessPriority'), R3 = col('ROP3Code'), ISO = col('ISOAlpha3')
|
||||
const data = records.slice(1).map(split).filter((f) => f.length === head.length)
|
||||
const expect = {
|
||||
all: data.length,
|
||||
unreached: data.filter((f) => ['0', '1', '2'].includes(f[L])).length,
|
||||
frontier: data.filter((f) => f[L] === '0').length,
|
||||
}
|
||||
const snap = JSON.parse(readFileSync(resolve(ROOT, 'data/people-groups/doxa-groups-snapshot.json'), 'utf8'))
|
||||
const lost = new Map(data.map((f) => [f[R3] + '|' + f[ISO], f[L]]))
|
||||
expect.doxa = snap.groups.length
|
||||
expect.doxaFrontier = snap.groups.filter((g) => lost.get(g.rop3 + '|' + g.country) === '0').length
|
||||
|
||||
const funnel = JSON.parse(readFileSync(resolve(ROOT, 'data/people-groups/vision-funnel.json'), 'utf8'))
|
||||
const got = Object.fromEntries(funnel.steps.map((s) => [s.id, s.groups]))
|
||||
got.doxaFrontier = funnel.steps.find((s) => s.id === 'doxa').frontier
|
||||
const bad = []
|
||||
for (const k of Object.keys(expect)) {
|
||||
const ok = expect[k] === got[k]
|
||||
console.log((ok ? 'ok ' : 'FAIL ') + k.padEnd(13) + ' recomputed ' + String(expect[k]).padStart(6) + ' vision-funnel.json ' + got[k])
|
||||
if (!ok) bad.push(k)
|
||||
}
|
||||
|
||||
const i = process.argv.indexOf('--url')
|
||||
if (i > -1) {
|
||||
const { chromium } = await import('playwright')
|
||||
const b = await chromium.launch()
|
||||
const p = await b.newPage({ viewport: { width: 900, height: 1000 } })
|
||||
await p.goto(process.argv[i + 1], { waitUntil: 'networkidle' })
|
||||
await p.waitForTimeout(1500)
|
||||
const shown = await p.evaluate(() => { const el = document.querySelector('doxa-vision-card'); const r = el && el.shadowRoot; return r ? [...r.querySelectorAll('.vc-n')].map((n) => n.textContent.trim()) : [] })
|
||||
const note = await p.evaluate(() => { const r = document.querySelector('doxa-vision-card')?.shadowRoot; return r ? r.querySelector('.vc-step-doxa .vc-note')?.textContent || '' : '' })
|
||||
await b.close()
|
||||
const want = [expect.all, expect.unreached, expect.frontier, expect.doxa].map((n) => new Intl.NumberFormat('en').format(n))
|
||||
const okBars = JSON.stringify(shown) === JSON.stringify(want)
|
||||
console.log((okBars ? 'ok ' : 'FAIL ') + 'rendered bars ' + JSON.stringify(shown) + (okBars ? '' : ' expected ' + JSON.stringify(want)))
|
||||
const okNote = note.includes(new Intl.NumberFormat('en').format(expect.doxaFrontier))
|
||||
console.log((okNote ? 'ok ' : 'FAIL ') + 'rendered DOXA note "' + note.trim() + '"')
|
||||
if (!okBars) bad.push('rendered bars'); if (!okNote) bad.push('rendered note')
|
||||
}
|
||||
console.log(bad.length ? 'VISION-CARD-NUMBERS: FAIL — ' + bad.join(', ') : 'VISION-CARD-NUMBERS: PASS — every number matches the data')
|
||||
process.exit(bad.length ? 1 : 0)
|
||||
|
|
@ -1438,6 +1438,8 @@ function main() {
|
|||
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
|
||||
// which no element fetches and which dropin.exclude already withholds).
|
||||
'people-groups/people-groups-slim.json',
|
||||
// …and its compact cut (build-points.mjs): what all-people-groups actually fetches, 0.74 MB.
|
||||
'people-groups/people-groups-points.json',
|
||||
// vendor — DataTable/TabulatorTable inject Tabulator from assetsBase at runtime.
|
||||
'vendor/tabulator/tabulator.min.js',
|
||||
'vendor/tabulator/tabulator.min.css',
|
||||
|
|
|
|||
13
library/i18n/locales/ar/allPeopleGroups.json
Normal file
13
library/i18n/locales/ar/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "كل مجموعات الشعوب",
|
||||
"locations": "{n} موقعًا (مجموعة × بلد)",
|
||||
"doxa": "مجموعات شعوب DOXA",
|
||||
"frontier": "حدودية — لم يبدأ شيء",
|
||||
"unreached": "غير مُبشَّرة — العمل يبدأ",
|
||||
"reached": "مُبشَّرة — الكنيسة موجودة",
|
||||
"hint": "انقر على صف لعرضها كلها · انقر مرة أخرى لعرض الكل",
|
||||
"loading": "جارٍ تحميل كل مجموعات الشعوب…",
|
||||
"loadError": "تعذّر تحميل الخريطة.",
|
||||
"pray": "صلِّ لأجلهم ←",
|
||||
"population": "السكان {n}"
|
||||
}
|
||||
21
library/i18n/locales/ar/vision.json
Normal file
21
library/i18n/locales/ar/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · الرؤية",
|
||||
"title": "كل شعب. كل مكان.",
|
||||
"tagline": "من كل مجموعات الشعوب في العالم إلى التي تصلي لأجلها DOXA، محسوبةً من البيانات.",
|
||||
"funnelLabel": "من كل مجموعات الشعوب إلى مجموعات شعوب DOXA",
|
||||
"allLabel": "مجموعات الشعوب",
|
||||
"allNote": "كل مجموعة شعب، في كل بلد تعيش فيه",
|
||||
"unreachedLabel": "غير المُبشَّرة",
|
||||
"unreachedNote": "أقل من 2% إنجيليون",
|
||||
"frontierLabel": "الحدودية",
|
||||
"frontierNote": "أقل من 0.1% إنجيليون",
|
||||
"doxaLabel": "مجموعات شعوب DOXA",
|
||||
"doxaNote": "{frontier} منها حدودية",
|
||||
"people": "{n} نسمة",
|
||||
"sources": "مجموعات الشعوب: {imb}، بيانات {date}. قائمة DOXA: pray.doxa.life، {date2}.",
|
||||
"howCounted": "كيف تُحسب هذه الأرقام",
|
||||
"ruleAll": "كل صف في بيانات IMB لمجموعات الشعوب: مجموعة شعب واحدة في بلد واحد.",
|
||||
"ruleUnreached": "أولوية الضياع لدى IMB من 0 إلى 2: أقل من 2% إنجيليون.",
|
||||
"ruleFrontier": "أولوية الضياع لدى IMB رقم 0: أقل من 0.1% إنجيليون. جزء من غير المُبشَّرة.",
|
||||
"ruleDoxa": "كل مجموعة في قائمة صلاة DOXA، مطابَقة مع بيانات IMB حسب الشعب والبلد. ليست كلها حدودية."
|
||||
}
|
||||
13
library/i18n/locales/de/allPeopleGroups.json
Normal file
13
library/i18n/locales/de/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Alle Volksgruppen",
|
||||
"locations": "{n} Standorte (Gruppe × Land)",
|
||||
"doxa": "DOXA-Volksgruppen",
|
||||
"frontier": "Frontier — noch nichts begonnen",
|
||||
"unreached": "Unerreicht — Arbeit beginnt",
|
||||
"reached": "Erreicht — Gemeinde ist da",
|
||||
"hint": "Zeile anklicken, um alle zu sehen · erneut klicken für alle",
|
||||
"loading": "Alle Volksgruppen werden geladen…",
|
||||
"loadError": "Die Karte konnte nicht geladen werden.",
|
||||
"pray": "Für sie beten →",
|
||||
"population": "Einw. {n}"
|
||||
}
|
||||
21
library/i18n/locales/de/vision.json
Normal file
21
library/i18n/locales/de/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Vision",
|
||||
"title": "Jedes Volk. Jeder Ort.",
|
||||
"tagline": "Von allen Volksgruppen der Welt bis zu denen, für die DOXA betet, aus den Daten gezählt.",
|
||||
"funnelLabel": "Von allen Volksgruppen zu den DOXA-Volksgruppen",
|
||||
"allLabel": "Volksgruppen",
|
||||
"allNote": "jede Volksgruppe in jedem Land, in dem sie lebt",
|
||||
"unreachedLabel": "Unerreicht",
|
||||
"unreachedNote": "unter 2 % Evangelikale",
|
||||
"frontierLabel": "Frontier",
|
||||
"frontierNote": "unter 0,1 % Evangelikale",
|
||||
"doxaLabel": "DOXA-Volksgruppen",
|
||||
"doxaNote": "{frontier} davon sind Frontier",
|
||||
"people": "{n} Menschen",
|
||||
"sources": "Volksgruppen: {imb}, Datenstand {date}. DOXA-Liste: pray.doxa.life, {date2}.",
|
||||
"howCounted": "So wird gezählt",
|
||||
"ruleAll": "Jede Zeile der IMB-Volksgruppendaten: eine Volksgruppe in einem Land.",
|
||||
"ruleUnreached": "IMB-Lostness-Priorität 0 bis 2: weniger als 2 % Evangelikale.",
|
||||
"ruleFrontier": "IMB-Lostness-Priorität 0: weniger als 0,1 % Evangelikale. Eine Teilmenge der Unerreichten.",
|
||||
"ruleDoxa": "Jede Gruppe auf der DOXA-Gebetsliste, den IMB-Daten nach Volk und Land zugeordnet. Nicht alle sind Frontier."
|
||||
}
|
||||
13
library/i18n/locales/en/allPeopleGroups.json
Normal file
13
library/i18n/locales/en/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "All People Groups",
|
||||
"locations": "{n} group–country locations",
|
||||
"doxa": "DOXA people groups",
|
||||
"frontier": "Frontier — nothing started",
|
||||
"unreached": "Unreached — work beginning",
|
||||
"reached": "Reached — church is there",
|
||||
"hint": "Click a row to see all of them · click again for everything",
|
||||
"loading": "Loading every people group…",
|
||||
"loadError": "The map could not be loaded.",
|
||||
"pray": "Pray for them →",
|
||||
"population": "pop {n}"
|
||||
}
|
||||
21
library/i18n/locales/en/vision.json
Normal file
21
library/i18n/locales/en/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Vision",
|
||||
"title": "Every people. Every place.",
|
||||
"tagline": "From every people group on earth to the ones DOXA prays for, counted from the data.",
|
||||
"funnelLabel": "From all people groups to DOXA people groups",
|
||||
"allLabel": "People groups",
|
||||
"allNote": "each people group, in each country it lives in",
|
||||
"unreachedLabel": "Unreached",
|
||||
"unreachedNote": "under 2% evangelical",
|
||||
"frontierLabel": "Frontier",
|
||||
"frontierNote": "under 0.1% evangelical",
|
||||
"doxaLabel": "DOXA people groups",
|
||||
"doxaNote": "{frontier} of them are frontier",
|
||||
"people": "{n} people",
|
||||
"sources": "People groups: {imb}, data of {date}. DOXA list: pray.doxa.life, {date2}.",
|
||||
"howCounted": "How these are counted",
|
||||
"ruleAll": "Every row of the IMB people-group data: one people group in one country.",
|
||||
"ruleUnreached": "IMB lostness priority 0 to 2: less than 2% evangelical.",
|
||||
"ruleFrontier": "IMB lostness priority 0: less than 0.1% evangelical. A subset of the unreached.",
|
||||
"ruleDoxa": "Every group on the DOXA prayer list, matched to the IMB data by people and country. Not all are frontier."
|
||||
}
|
||||
13
library/i18n/locales/es/allPeopleGroups.json
Normal file
13
library/i18n/locales/es/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Todos los grupos étnicos",
|
||||
"locations": "{n} ubicaciones (grupo × país)",
|
||||
"doxa": "Grupos étnicos de DOXA",
|
||||
"frontier": "Frontera — nada iniciado",
|
||||
"unreached": "No alcanzados — el trabajo comienza",
|
||||
"reached": "Alcanzados — la iglesia está ahí",
|
||||
"hint": "Haga clic en una fila para verlos todos · de nuevo para ver todo",
|
||||
"loading": "Cargando todos los grupos étnicos…",
|
||||
"loadError": "No se pudo cargar el mapa.",
|
||||
"pray": "Ore por ellos →",
|
||||
"population": "pobl. {n}"
|
||||
}
|
||||
21
library/i18n/locales/es/vision.json
Normal file
21
library/i18n/locales/es/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Visión",
|
||||
"title": "Cada pueblo. Cada lugar.",
|
||||
"tagline": "De todos los grupos étnicos del mundo a aquellos por los que DOXA ora, contados a partir de los datos.",
|
||||
"funnelLabel": "De todos los grupos étnicos a los grupos étnicos de DOXA",
|
||||
"allLabel": "Grupos étnicos",
|
||||
"allNote": "cada grupo étnico, en cada país donde vive",
|
||||
"unreachedLabel": "No alcanzados",
|
||||
"unreachedNote": "menos del 2 % evangélicos",
|
||||
"frontierLabel": "Frontera",
|
||||
"frontierNote": "menos del 0,1 % evangélicos",
|
||||
"doxaLabel": "Grupos étnicos de DOXA",
|
||||
"doxaNote": "{frontier} de ellos son de frontera",
|
||||
"people": "{n} personas",
|
||||
"sources": "Grupos étnicos: {imb}, datos de {date}. Lista de DOXA: pray.doxa.life, {date2}.",
|
||||
"howCounted": "Cómo se cuentan",
|
||||
"ruleAll": "Cada fila de los datos de grupos étnicos del IMB: un grupo étnico en un país.",
|
||||
"ruleUnreached": "Prioridad de perdición del IMB de 0 a 2: menos del 2 % evangélicos.",
|
||||
"ruleFrontier": "Prioridad de perdición del IMB 0: menos del 0,1 % evangélicos. Un subconjunto de los no alcanzados.",
|
||||
"ruleDoxa": "Cada grupo de la lista de oración de DOXA, vinculado a los datos del IMB por pueblo y país. No todos son de frontera."
|
||||
}
|
||||
13
library/i18n/locales/fr/allPeopleGroups.json
Normal file
13
library/i18n/locales/fr/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Tous les groupes ethniques",
|
||||
"locations": "{n} emplacements (groupe × pays)",
|
||||
"doxa": "Groupes ethniques DOXA",
|
||||
"frontier": "Frontière — rien n’a commencé",
|
||||
"unreached": "Non atteints — le travail commence",
|
||||
"reached": "Atteints — l’Église est là",
|
||||
"hint": "Cliquez sur une ligne pour les voir tous · recliquez pour tout voir",
|
||||
"loading": "Chargement de tous les groupes ethniques…",
|
||||
"loadError": "La carte n’a pas pu être chargée.",
|
||||
"pray": "Priez pour eux →",
|
||||
"population": "pop. {n}"
|
||||
}
|
||||
21
library/i18n/locales/fr/vision.json
Normal file
21
library/i18n/locales/fr/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Vision",
|
||||
"title": "Chaque peuple. Chaque lieu.",
|
||||
"tagline": "De tous les groupes ethniques du monde à ceux pour qui DOXA prie, comptés à partir des données.",
|
||||
"funnelLabel": "De tous les groupes ethniques aux groupes ethniques DOXA",
|
||||
"allLabel": "Groupes ethniques",
|
||||
"allNote": "chaque groupe ethnique, dans chaque pays où il vit",
|
||||
"unreachedLabel": "Non atteints",
|
||||
"unreachedNote": "moins de 2 % d’évangéliques",
|
||||
"frontierLabel": "Frontière",
|
||||
"frontierNote": "moins de 0,1 % d’évangéliques",
|
||||
"doxaLabel": "Groupes ethniques DOXA",
|
||||
"doxaNote": "dont {frontier} de frontière",
|
||||
"people": "{n} personnes",
|
||||
"sources": "Groupes ethniques : {imb}, données du {date}. Liste DOXA : pray.doxa.life, {date2}.",
|
||||
"howCounted": "Comment ces chiffres sont comptés",
|
||||
"ruleAll": "Chaque ligne des données IMB : un groupe ethnique dans un pays.",
|
||||
"ruleUnreached": "Priorité de perdition IMB 0 à 2 : moins de 2 % d’évangéliques.",
|
||||
"ruleFrontier": "Priorité de perdition IMB 0 : moins de 0,1 % d’évangéliques. Un sous-ensemble des non-atteints.",
|
||||
"ruleDoxa": "Chaque groupe de la liste de prière DOXA, rapproché des données IMB par peuple et pays. Tous ne sont pas de frontière."
|
||||
}
|
||||
13
library/i18n/locales/hi/allPeopleGroups.json
Normal file
13
library/i18n/locales/hi/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "सभी जनसमूह",
|
||||
"locations": "{n} स्थान (समूह × देश)",
|
||||
"doxa": "DOXA जनसमूह",
|
||||
"frontier": "सीमांत — कुछ शुरू नहीं हुआ",
|
||||
"unreached": "अप्रचारित — काम शुरू हो रहा है",
|
||||
"reached": "प्रचारित — कलीसिया मौजूद है",
|
||||
"hint": "सभी देखने के लिए पंक्ति पर क्लिक करें · सब कुछ देखने के लिए फिर क्लिक करें",
|
||||
"loading": "सभी जनसमूह लोड हो रहे हैं…",
|
||||
"loadError": "मानचित्र लोड नहीं हो सका।",
|
||||
"pray": "उनके लिए प्रार्थना करें →",
|
||||
"population": "जनसंख्या {n}"
|
||||
}
|
||||
21
library/i18n/locales/hi/vision.json
Normal file
21
library/i18n/locales/hi/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · दर्शन",
|
||||
"title": "हर जनसमूह। हर स्थान।",
|
||||
"tagline": "दुनिया के हर जनसमूह से उन तक जिनके लिए DOXA प्रार्थना करता है, आँकड़ों से गिने गए।",
|
||||
"funnelLabel": "सभी जनसमूहों से DOXA जनसमूहों तक",
|
||||
"allLabel": "जनसमूह",
|
||||
"allNote": "हर जनसमूह, हर उस देश में जहाँ वह रहता है",
|
||||
"unreachedLabel": "अप्रचारित",
|
||||
"unreachedNote": "2% से कम इवेंजेलिकल",
|
||||
"frontierLabel": "सीमांत",
|
||||
"frontierNote": "0.1% से कम इवेंजेलिकल",
|
||||
"doxaLabel": "DOXA जनसमूह",
|
||||
"doxaNote": "इनमें से {frontier} सीमांत हैं",
|
||||
"people": "{n} लोग",
|
||||
"sources": "जनसमूह: {imb}, {date} के आँकड़े। DOXA सूची: pray.doxa.life, {date2}।",
|
||||
"howCounted": "ये कैसे गिने गए",
|
||||
"ruleAll": "IMB जनसमूह आँकड़ों की हर पंक्ति: एक देश में एक जनसमूह।",
|
||||
"ruleUnreached": "IMB लॉस्टनेस प्राथमिकता 0 से 2: 2% से कम इवेंजेलिकल।",
|
||||
"ruleFrontier": "IMB लॉस्टनेस प्राथमिकता 0: 0.1% से कम इवेंजेलिकल। अप्रचारितों का एक भाग।",
|
||||
"ruleDoxa": "DOXA प्रार्थना सूची का हर समूह, जनसमूह और देश के आधार पर IMB आँकड़ों से मिलाया गया। सभी सीमांत नहीं हैं।"
|
||||
}
|
||||
13
library/i18n/locales/it/allPeopleGroups.json
Normal file
13
library/i18n/locales/it/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Tutti i gruppi etnici",
|
||||
"locations": "{n} località (gruppo × paese)",
|
||||
"doxa": "Gruppi etnici DOXA",
|
||||
"frontier": "Frontiera — non è iniziato nulla",
|
||||
"unreached": "Non raggiunti — il lavoro inizia",
|
||||
"reached": "Raggiunti — la chiesa c’è",
|
||||
"hint": "Fai clic su una riga per vederli tutti · di nuovo per vedere tutto",
|
||||
"loading": "Caricamento di tutti i gruppi etnici…",
|
||||
"loadError": "Impossibile caricare la mappa.",
|
||||
"pray": "Prega per loro →",
|
||||
"population": "pop. {n}"
|
||||
}
|
||||
21
library/i18n/locales/it/vision.json
Normal file
21
library/i18n/locales/it/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Visione",
|
||||
"title": "Ogni popolo. Ogni luogo.",
|
||||
"tagline": "Da tutti i gruppi etnici del mondo a quelli per cui DOXA prega, contati dai dati.",
|
||||
"funnelLabel": "Da tutti i gruppi etnici ai gruppi etnici DOXA",
|
||||
"allLabel": "Gruppi etnici",
|
||||
"allNote": "ogni gruppo etnico, in ogni paese in cui vive",
|
||||
"unreachedLabel": "Non raggiunti",
|
||||
"unreachedNote": "meno del 2% evangelici",
|
||||
"frontierLabel": "Frontiera",
|
||||
"frontierNote": "meno dello 0,1% evangelici",
|
||||
"doxaLabel": "Gruppi etnici DOXA",
|
||||
"doxaNote": "{frontier} di questi sono di frontiera",
|
||||
"people": "{n} persone",
|
||||
"sources": "Gruppi etnici: {imb}, dati del {date}. Elenco DOXA: pray.doxa.life, {date2}.",
|
||||
"howCounted": "Come vengono contati",
|
||||
"ruleAll": "Ogni riga dei dati IMB sui gruppi etnici: un gruppo etnico in un paese.",
|
||||
"ruleUnreached": "Priorità di perdizione IMB da 0 a 2: meno del 2% evangelici.",
|
||||
"ruleFrontier": "Priorità di perdizione IMB 0: meno dello 0,1% evangelici. Un sottoinsieme dei non raggiunti.",
|
||||
"ruleDoxa": "Ogni gruppo dell’elenco di preghiera DOXA, abbinato ai dati IMB per popolo e paese. Non tutti sono di frontiera."
|
||||
}
|
||||
13
library/i18n/locales/pt/allPeopleGroups.json
Normal file
13
library/i18n/locales/pt/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Todos os grupos étnicos",
|
||||
"locations": "{n} locais (grupo × país)",
|
||||
"doxa": "Grupos étnicos da DOXA",
|
||||
"frontier": "Fronteira — nada iniciado",
|
||||
"unreached": "Não alcançados — o trabalho começa",
|
||||
"reached": "Alcançados — a igreja está lá",
|
||||
"hint": "Clique em uma linha para ver todos · clique de novo para ver tudo",
|
||||
"loading": "Carregando todos os grupos étnicos…",
|
||||
"loadError": "Não foi possível carregar o mapa.",
|
||||
"pray": "Ore por eles →",
|
||||
"population": "pop. {n}"
|
||||
}
|
||||
21
library/i18n/locales/pt/vision.json
Normal file
21
library/i18n/locales/pt/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Visão",
|
||||
"title": "Cada povo. Cada lugar.",
|
||||
"tagline": "De todos os grupos étnicos do mundo àqueles pelos quais a DOXA ora, contados a partir dos dados.",
|
||||
"funnelLabel": "De todos os grupos étnicos aos grupos étnicos da DOXA",
|
||||
"allLabel": "Grupos étnicos",
|
||||
"allNote": "cada grupo étnico, em cada país onde vive",
|
||||
"unreachedLabel": "Não alcançados",
|
||||
"unreachedNote": "menos de 2% evangélicos",
|
||||
"frontierLabel": "Fronteira",
|
||||
"frontierNote": "menos de 0,1% evangélicos",
|
||||
"doxaLabel": "Grupos étnicos da DOXA",
|
||||
"doxaNote": "{frontier} deles são de fronteira",
|
||||
"people": "{n} pessoas",
|
||||
"sources": "Grupos étnicos: {imb}, dados de {date}. Lista da DOXA: pray.doxa.life, {date2}.",
|
||||
"howCounted": "Como são contados",
|
||||
"ruleAll": "Cada linha dos dados de grupos étnicos do IMB: um grupo étnico em um país.",
|
||||
"ruleUnreached": "Prioridade de perdição do IMB de 0 a 2: menos de 2% evangélicos.",
|
||||
"ruleFrontier": "Prioridade de perdição do IMB 0: menos de 0,1% evangélicos. Um subconjunto dos não alcançados.",
|
||||
"ruleDoxa": "Cada grupo da lista de oração da DOXA, associado aos dados do IMB por povo e país. Nem todos são de fronteira."
|
||||
}
|
||||
13
library/i18n/locales/ro/allPeopleGroups.json
Normal file
13
library/i18n/locales/ro/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Toate grupurile etnice",
|
||||
"locations": "{n} locații (grup × țară)",
|
||||
"doxa": "Grupuri etnice DOXA",
|
||||
"frontier": "Frontieră — nimic început",
|
||||
"unreached": "Neatinse — lucrarea începe",
|
||||
"reached": "Atinse — biserica este acolo",
|
||||
"hint": "Faceți clic pe un rând pentru a le vedea pe toate · din nou pentru tot",
|
||||
"loading": "Se încarcă toate grupurile etnice…",
|
||||
"loadError": "Harta nu a putut fi încărcată.",
|
||||
"pray": "Rugați-vă pentru ei →",
|
||||
"population": "pop. {n}"
|
||||
}
|
||||
21
library/i18n/locales/ro/vision.json
Normal file
21
library/i18n/locales/ro/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Viziune",
|
||||
"title": "Fiecare popor. Fiecare loc.",
|
||||
"tagline": "De la toate grupurile etnice ale lumii la cele pentru care se roagă DOXA, numărate din date.",
|
||||
"funnelLabel": "De la toate grupurile etnice la grupurile etnice DOXA",
|
||||
"allLabel": "Grupuri etnice",
|
||||
"allNote": "fiecare grup etnic, în fiecare țară în care trăiește",
|
||||
"unreachedLabel": "Neatinse",
|
||||
"unreachedNote": "sub 2% evanghelici",
|
||||
"frontierLabel": "Frontieră",
|
||||
"frontierNote": "sub 0,1% evanghelici",
|
||||
"doxaLabel": "Grupuri etnice DOXA",
|
||||
"doxaNote": "{frontier} dintre ele sunt de frontieră",
|
||||
"people": "{n} oameni",
|
||||
"sources": "Grupuri etnice: {imb}, date din {date}. Lista DOXA: pray.doxa.life, {date2}.",
|
||||
"howCounted": "Cum sunt numărate",
|
||||
"ruleAll": "Fiecare rând din datele IMB despre grupuri etnice: un grup etnic într-o țară.",
|
||||
"ruleUnreached": "Prioritatea de pierzare IMB 0–2: sub 2% evanghelici.",
|
||||
"ruleFrontier": "Prioritatea de pierzare IMB 0: sub 0,1% evanghelici. O submulțime a celor neatinse.",
|
||||
"ruleDoxa": "Fiecare grup de pe lista de rugăciune DOXA, potrivit cu datele IMB după popor și țară. Nu toate sunt de frontieră."
|
||||
}
|
||||
13
library/i18n/locales/ru/allPeopleGroups.json
Normal file
13
library/i18n/locales/ru/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Все народы",
|
||||
"locations": "{n} мест (народ × страна)",
|
||||
"doxa": "Народы DOXA",
|
||||
"frontier": "Рубежные — ничего не начато",
|
||||
"unreached": "Недостигнутые — работа начинается",
|
||||
"reached": "Достигнутые — церковь есть",
|
||||
"hint": "Нажмите на строку, чтобы увидеть все · ещё раз — чтобы показать всё",
|
||||
"loading": "Загрузка всех народов…",
|
||||
"loadError": "Не удалось загрузить карту.",
|
||||
"pray": "Молиться за них →",
|
||||
"population": "нас. {n}"
|
||||
}
|
||||
21
library/i18n/locales/ru/vision.json
Normal file
21
library/i18n/locales/ru/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · Видение",
|
||||
"title": "Каждый народ. Каждое место.",
|
||||
"tagline": "От всех народов мира до тех, за кого молится DOXA, — по данным.",
|
||||
"funnelLabel": "От всех народов до народов DOXA",
|
||||
"allLabel": "Народы",
|
||||
"allNote": "каждый народ в каждой стране, где он живёт",
|
||||
"unreachedLabel": "Недостигнутые",
|
||||
"unreachedNote": "менее 2% евангельских христиан",
|
||||
"frontierLabel": "Рубежные",
|
||||
"frontierNote": "менее 0,1% евангельских христиан",
|
||||
"doxaLabel": "Народы DOXA",
|
||||
"doxaNote": "из них рубежных: {frontier}",
|
||||
"people": "{n} человек",
|
||||
"sources": "Народы: {imb}, данные на {date}. Список DOXA: pray.doxa.life, {date2}.",
|
||||
"howCounted": "Как это посчитано",
|
||||
"ruleAll": "Каждая строка данных IMB о народах: один народ в одной стране.",
|
||||
"ruleUnreached": "Приоритет IMB 0–2: менее 2% евангельских христиан.",
|
||||
"ruleFrontier": "Приоритет IMB 0: менее 0,1% евангельских христиан. Часть недостигнутых.",
|
||||
"ruleDoxa": "Каждая группа из молитвенного списка DOXA, сопоставленная с данными IMB по народу и стране. Не все из них рубежные."
|
||||
}
|
||||
13
library/i18n/locales/zh/allPeopleGroups.json
Normal file
13
library/i18n/locales/zh/allPeopleGroups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "所有族群",
|
||||
"locations": "{n} 个族群–国家位置",
|
||||
"doxa": "DOXA 族群",
|
||||
"frontier": "前沿族群 — 尚未开始",
|
||||
"unreached": "未得之民 — 事工起步",
|
||||
"reached": "已得之民 — 教会已在",
|
||||
"hint": "点击一行查看全部 · 再次点击显示所有",
|
||||
"loading": "正在加载所有族群…",
|
||||
"loadError": "无法加载地图。",
|
||||
"pray": "为他们祷告 →",
|
||||
"population": "人口 {n}"
|
||||
}
|
||||
21
library/i18n/locales/zh/vision.json
Normal file
21
library/i18n/locales/zh/vision.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"kicker": "DOXA · 异象",
|
||||
"title": "每一个民族,每一个地方。",
|
||||
"tagline": "从世界上所有族群到 DOXA 为之祷告的族群,全部依据数据计算。",
|
||||
"funnelLabel": "从所有族群到 DOXA 族群",
|
||||
"allLabel": "族群",
|
||||
"allNote": "每个族群在其居住的每个国家各计一次",
|
||||
"unreachedLabel": "未得之民",
|
||||
"unreachedNote": "福音派信徒少于 2%",
|
||||
"frontierLabel": "前沿族群",
|
||||
"frontierNote": "福音派信徒少于 0.1%",
|
||||
"doxaLabel": "DOXA 族群",
|
||||
"doxaNote": "其中 {frontier} 个为前沿族群",
|
||||
"people": "{n} 人",
|
||||
"sources": "族群:{imb},数据日期 {date}。DOXA 名单:pray.doxa.life,{date2}。",
|
||||
"howCounted": "这些数字如何计算",
|
||||
"ruleAll": "IMB 族群数据的每一行:一个国家中的一个族群。",
|
||||
"ruleUnreached": "IMB 迷失优先级 0 至 2:福音派信徒少于 2%。",
|
||||
"ruleFrontier": "IMB 迷失优先级 0:福音派信徒少于 0.1%。属于未得之民的一部分。",
|
||||
"ruleDoxa": "DOXA 祷告名单上的每个族群,按民族和国家与 IMB 数据匹配。并非全部都是前沿族群。"
|
||||
}
|
||||
55
library/scripts/people-groups/build-points.mjs
Normal file
55
library/scripts/people-groups/build-points.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-points.mjs — the COMPACT point file All People Groups renders from.
|
||||
*
|
||||
* node library/scripts/people-groups/build-points.mjs
|
||||
* reads data/people-groups/people-groups-slim.json (12,379 rows × 22 keys, ~7.5 MB)
|
||||
* writes data/people-groups/people-groups-points.json (only the 10 fields the map uses)
|
||||
*
|
||||
* WHY. The map downloaded and parsed the whole slim file — every column, as one object per row — to
|
||||
* draw ten fields. That, plus waiting on the live prayer API before drawing anything, is most of why
|
||||
* the Driver found it "not even loading, takes a very long time". This file keeps the same rows and
|
||||
* values in column order, with the repetitive strings (country, religion, evangelical level,
|
||||
* language) stored once in dictionaries and referenced by index, and coordinates at 3 decimals
|
||||
* (~100 m, far below a pin's size at any zoom this map shows).
|
||||
*
|
||||
* Deterministic: same input, byte-identical output. Re-run after refreshing the slim file.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const SRC = resolve(ROOT, 'data/people-groups/people-groups-slim.json')
|
||||
const OUT = resolve(ROOT, 'data/people-groups/people-groups-points.json')
|
||||
|
||||
const slim = JSON.parse(readFileSync(SRC, 'utf8'))
|
||||
const dicts = { country: [], religion: [], evangelical: [], language: [] }
|
||||
const idx = { country: new Map(), religion: new Map(), evangelical: new Map(), language: new Map() }
|
||||
const ref = (k, v) => { const s = String(v || ''); let i = idx[k].get(s); if (i === undefined) { i = dicts[k].length; dicts[k].push(s); idx[k].set(s, i) } return i }
|
||||
const r3 = (x) => Math.round(Number(x) * 1000) / 1000
|
||||
|
||||
const rows = []
|
||||
for (const r of slim.rows || []) {
|
||||
const lat = Number(r.Lat), lng = Number(r.Long)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue
|
||||
rows.push([
|
||||
r3(lng), r3(lat),
|
||||
r.GSEC === '' || r.GSEC == null ? null : Number(r.GSEC),
|
||||
String(r.ROP3Code || ''),
|
||||
String(r.Name || ''),
|
||||
ref('country', r.CountryName),
|
||||
Number(r.Population) || 0,
|
||||
ref('language', r.LanguageName),
|
||||
ref('religion', r.ReligionName),
|
||||
ref('evangelical', r.EvangelicalLevel),
|
||||
])
|
||||
}
|
||||
const out = {
|
||||
meta: { ...(slim.meta || {}), derivedFrom: 'people-groups-slim.json via library/scripts/people-groups/build-points.mjs', rows: rows.length },
|
||||
cols: ['lng', 'lat', 'gsec', 'rop3', 'name', 'country', 'population', 'language', 'religion', 'evangelical'],
|
||||
dicts,
|
||||
rows,
|
||||
}
|
||||
writeFileSync(OUT, JSON.stringify(out))
|
||||
console.log(`people-groups-points.json: ${rows.length} rows, ${(JSON.stringify(out).length / 1e6).toFixed(2)} MB`)
|
||||
112
library/scripts/people-groups/build-vision-funnel.mjs
Normal file
112
library/scripts/people-groups/build-vision-funnel.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-vision-funnel.mjs — every number on the DOXA Vision Card, computed from data, never typed.
|
||||
*
|
||||
* node library/scripts/people-groups/build-vision-funnel.mjs (refresh the DOXA snapshot too)
|
||||
* node library/scripts/people-groups/build-vision-funnel.mjs --offline (reuse the saved snapshot)
|
||||
*
|
||||
* READS data/people-groups/people_groups.csv IMB / peoplegroups.org, one row per group × country
|
||||
* https://pray.doxa.life/api/people-groups/list the live DOXA list (saved as a snapshot below)
|
||||
* WRITES data/people-groups/doxa-groups-snapshot.json the DOXA list as fetched: slug, ROP3, country
|
||||
* data/people-groups/vision-funnel.json the four funnel steps, counts and populations
|
||||
*
|
||||
* THE RULES (documented for readers in plugin-designs/doxa-vision-card/HOW-THE-NUMBERS-ARE-COUNTED.md):
|
||||
* people groups every row of the IMB dataset (a people group in one country)
|
||||
* unreached IMB Lostness Priority 0–2 (Frontier / Pioneer / Expanding Unreached: < 2% evangelical)
|
||||
* frontier IMB Lostness Priority 0 ("Frontier Unreached People Group": < 0.1% evangelical)
|
||||
* DOXA groups every group on the live DOXA list, matched to the IMB row by (ROP3, country)
|
||||
* The first three NEST (each is a subset of the one before). DOXA groups do not nest inside frontier,
|
||||
* so the card says how many of them are frontier rather than implying all are.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const CSV = resolve(ROOT, 'data/people-groups/people_groups.csv')
|
||||
const SNAP = resolve(ROOT, 'data/people-groups/doxa-groups-snapshot.json')
|
||||
const OUT = resolve(ROOT, 'data/people-groups/vision-funnel.json')
|
||||
const OFFLINE = process.argv.includes('--offline')
|
||||
|
||||
// Minimal RFC-4180 CSV parser (quoted fields, doubled quotes, newlines inside quotes).
|
||||
function parseCsv(text) {
|
||||
const rows = []; let row = []; let f = ''; let q = false
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]
|
||||
if (q) {
|
||||
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++ } else q = false } else f += c
|
||||
} else if (c === '"') q = true
|
||||
else if (c === ',') { row.push(f); f = '' }
|
||||
else if (c === '\n') { row.push(f); rows.push(row); row = []; f = '' }
|
||||
else if (c !== '\r') f += c
|
||||
}
|
||||
if (f || row.length) { row.push(f); rows.push(row) }
|
||||
const head = rows.shift().map((h) => h.replace(/^/, ''))
|
||||
return rows.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i]])))
|
||||
}
|
||||
|
||||
const rows = parseCsv(readFileSync(CSV, 'utf8'))
|
||||
const pop = (r) => Number(r.Population) || 0
|
||||
const lostness = (r) => Number(r.LostnessPriority)
|
||||
const isUnreached = (r) => lostness(r) >= 0 && lostness(r) <= 2
|
||||
const isFrontier = (r) => lostness(r) === 0
|
||||
const sum = (xs) => xs.reduce((a, r) => a + pop(r), 0)
|
||||
|
||||
// The DOXA list: live unless --offline, always saved so the numbers can be re-derived later.
|
||||
let snapshot = null
|
||||
if (!OFFLINE) {
|
||||
try {
|
||||
const url = 'https://pray.doxa.life/api/people-groups/list?fields=slug,imb_reg_of_people_3,country_code'
|
||||
// pray.doxa.life answers 403 to a script User-Agent (AGENT-BRIEF): send a browser one.
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36' } })
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
const posts = (await res.json()).posts || []
|
||||
const cc = (p) => (p.country_code && typeof p.country_code === 'object' ? p.country_code.value : p.country_code) || ''
|
||||
snapshot = {
|
||||
source: 'https://pray.doxa.life/api/people-groups/list',
|
||||
fetched: new Date().toISOString().slice(0, 10),
|
||||
groups: posts.map((p) => ({ slug: p.slug, rop3: String(p.imb_reg_of_people_3 || ''), country: cc(p) })).sort((a, b) => a.slug.localeCompare(b.slug)),
|
||||
}
|
||||
writeFileSync(SNAP, JSON.stringify(snapshot, null, 1) + '\n')
|
||||
} catch (e) {
|
||||
console.warn('DOXA list unreachable (' + e.message + '); using the saved snapshot')
|
||||
}
|
||||
}
|
||||
if (!snapshot) {
|
||||
if (!existsSync(SNAP)) { console.error('no DOXA snapshot and the list is unreachable'); process.exit(1) }
|
||||
snapshot = JSON.parse(readFileSync(SNAP, 'utf8'))
|
||||
}
|
||||
|
||||
const byKey = new Map(rows.map((r) => [r.ROP3Code + '|' + r.ISOAlpha3, r]))
|
||||
const doxaRows = []
|
||||
let unmatched = 0
|
||||
for (const g of snapshot.groups) {
|
||||
const r = byKey.get(g.rop3 + '|' + g.country)
|
||||
if (r) doxaRows.push(r); else unmatched++
|
||||
}
|
||||
const unreached = rows.filter(isUnreached)
|
||||
const frontier = rows.filter(isFrontier)
|
||||
const captured = rows.map((r) => String(r.CaptureDate || '').slice(0, 10)).filter(Boolean).sort().pop() || ''
|
||||
|
||||
const out = {
|
||||
sources: {
|
||||
imb: { file: 'data/people-groups/people_groups.csv', publisher: 'IMB / peoplegroups.org', captured },
|
||||
doxa: { file: 'data/people-groups/doxa-groups-snapshot.json', source: snapshot.source, fetched: snapshot.fetched },
|
||||
},
|
||||
steps: [
|
||||
{ id: 'all', groups: rows.length, population: sum(rows) },
|
||||
{ id: 'unreached', groups: unreached.length, population: sum(unreached) },
|
||||
{ id: 'frontier', groups: frontier.length, population: sum(frontier) },
|
||||
{
|
||||
id: 'doxa',
|
||||
groups: snapshot.groups.length,
|
||||
population: sum(doxaRows),
|
||||
frontier: doxaRows.filter(isFrontier).length,
|
||||
unreached: doxaRows.filter(isUnreached).length,
|
||||
matched: doxaRows.length,
|
||||
unmatched,
|
||||
},
|
||||
],
|
||||
}
|
||||
writeFileSync(OUT, JSON.stringify(out, null, 1) + '\n')
|
||||
for (const s of out.steps) console.log(s.id.padEnd(10), String(s.groups).padStart(6), 'groups ·', (s.population / 1e9).toFixed(2), 'bn' + (s.id === 'doxa' ? ` · ${s.frontier} frontier · ${s.unreached} unreached · ${s.unmatched} unmatched` : ''))
|
||||
|
|
@ -125,6 +125,8 @@
|
|||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
|
||||
"check:url-resolver": "node internal/check-url-resolver.mjs",
|
||||
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
|
||||
"check:vision-card-numbers": "node internal/check-vision-card-numbers.mjs",
|
||||
"check:map-embed-cells": "node internal/check-map-embed-cells.mjs",
|
||||
"check:map-unmount-race": "node internal/check-map-unmount-guard.mjs",
|
||||
"check:map-unmount-mid-init": "node internal/check-map-unmount-mid-init.mjs",
|
||||
"smoke:studio": "node internal/smoke-studio.mjs",
|
||||
|
|
@ -163,7 +165,6 @@
|
|||
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
|
||||
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
|
||||
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs",
|
||||
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs",
|
||||
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `the check:stale-sweep-claim:poison entry`. Static, sub-second.",
|
||||
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs",
|
||||
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
|
||||
|
|
@ -285,7 +286,9 @@
|
|||
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.",
|
||||
"check:map-unmount-race": "browser-free, sub-second: runs the shipped ensureEngineCSS with a null element (what an unmounted pane hands it) against a copy with its guard stripped; the guarded arm must resolve and the stripped arm must throw \"reading getRootNode\". By gospel-plugin, adopted by studio.",
|
||||
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged.",
|
||||
"check:composed-filters": "CELL C — the gospel map composed above its own tables. Asserts the composite keeps the reader INSIDE it: \"Every resource in <language>\" must be a <button> that filters in place, not an <a> that navigates away and throws out the map the reader just positioned. Checks the tag, that the URL did not change, that the table NARROWED (row count down but not to zero), that the pin count survived, and that nothing 4xx'd. Picks a tier-2 pin from the live source — the first version opened pin 0, a group with nothing found, and failed a working composite on an empty table. Browser gate: run under both rendezvous locks, product lock first."
|
||||
"check:composed-filters": "CELL C — the gospel map composed above its own tables. Asserts the composite keeps the reader INSIDE it: \"Every resource in <language>\" must be a <button> that filters in place, not an <a> that navigates away and throws out the map the reader just positioned. Checks the tag, that the URL did not change, that the table NARROWED (row count down but not to zero), that the pin count survived, and that nothing 4xx'd. Picks a tier-2 pin from the live source — the first version opened pin 0, a group with nothing found, and failed a working composite on an empty table. Browser gate: run under both rendezvous locks, product lock first.",
|
||||
"check:vision-card-numbers": "the DOXA Vision Card must show the numbers the DATA gives: independently recomputes all four funnel steps (and DOXA-in-frontier) from people_groups.csv + the saved DOXA list, compares with vision-funnel.json, and with --url also with the numbers painted in the card. A typed-in number, a stale funnel file or a rendering bug fails it.",
|
||||
"check:map-embed-cells": "PRODUCTION-MATRIX cells B (iframe) and C (script embed) for the seven Research Gallery maps on a genuinely foreign origin (tree on 127.0.0.1, partner page on localhost). C passes the HOST a distinct token and fails if any Mapbox request uses another. --only bundle/profile,… --cell B|C."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -19,12 +19,18 @@
|
|||
* YELLOW unreached — GSEC 2–3: <2% evangelical, active church-planting work
|
||||
* GREEN reached — GSEC 4–6: 2%+ evangelical
|
||||
*
|
||||
* DATA: the shared static dataset assets/people-groups/people-groups-slim.json
|
||||
* (built from the public peoplegroups.org CSV — one row per group×country) + the
|
||||
* live DOXA set via the standard pray-tools pipeline. If the API is unreachable
|
||||
* the map still renders fully by GSEC — the black layer just stays empty.
|
||||
* DATA: assets/people-groups/people-groups-points.json — the public peoplegroups.org rows (one per
|
||||
* group×country) cut to the ten fields this map uses, with repeated strings in dictionaries
|
||||
* (library/scripts/people-groups/build-points.mjs; 0.74 MB instead of the 7.5 MB slim file) — plus
|
||||
* the live DOXA set via the standard pray-tools pipeline.
|
||||
*
|
||||
* ★ LOAD ORDER (2026-09-25, the Driver: "not even loading, takes a very long time"). Pins used to
|
||||
* wait for BOTH the 7.5 MB file AND the live prayer API before anything drew. Now the compact
|
||||
* file alone draws every pin by GSEC, and the DOXA overlay recolours its pins black when the API
|
||||
* answers. A slow or unreachable API costs the black layer, never the map.
|
||||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { useMapData } from '@map/composables/useMapData.js'
|
||||
|
|
@ -95,15 +101,41 @@ const mapProvider = resolveMapProvider(profileConfig)
|
|||
// is served below the site root, and inside the Research Gallery no assetsBase is passed at all.
|
||||
const assetsBase = computed(() => resolveAssetsBase(profileConfig?.value || {}))
|
||||
|
||||
// ─── Words (library/i18n/locales/<lc>/allPeopleGroups.json, all 11 locales) ────
|
||||
// Guarded like FilterDropdown: ProfileLoader installs vue-i18n for this profile, but an unguarded
|
||||
// useI18n() throws on any host without it, and this map must never go blank over a label.
|
||||
const EN = { title: 'All People Groups', locations: '{n} group–country locations', doxa: 'DOXA people groups',
|
||||
frontier: 'Frontier — nothing started', unreached: 'Unreached — work beginning', reached: 'Reached — church is there',
|
||||
hint: 'Click a row to see all of them · click again for everything', loading: 'Loading every people group…',
|
||||
loadError: 'The map could not be loaded.', pray: 'Pray for them →', population: 'pop {n}' }
|
||||
let i18n = null
|
||||
try { i18n = useI18n() } catch (_) { /* no i18n on this host: English below */ }
|
||||
function tr(key, vars) {
|
||||
const full = 'allPeopleGroups.' + key
|
||||
// vue-i18n treats {n} as ITS placeholder: pass the values to t(), or it blanks them out before any
|
||||
// later substitution sees them (the legend read "group–country locations" with no number).
|
||||
if (i18n && i18n.te && i18n.te(full)) return i18n.t(full, vars || {})
|
||||
let v = EN[key]
|
||||
if (vars) for (const k in vars) v = String(v).split('{' + k + '}').join(String(vars[k]))
|
||||
return v
|
||||
}
|
||||
const uiLocale = computed(() => (i18n && i18n.locale && i18n.locale.value) || 'en')
|
||||
const fmt = (n) => { try { return Number(n).toLocaleString(uiLocale.value) } catch (_) { return Number(n).toLocaleString() } }
|
||||
|
||||
// ─── The four categories (the Driver's palette) ──────────────────────────────
|
||||
// Tuned for the LIGHT basemap (2026-09-25; was a near-black map, where the black DOXA pins vanished).
|
||||
// Same four meanings and hues; yellow and green are a step darker because pure #f1c40f / #2ecc71
|
||||
// wash out on near-white, and every pin carries a thin dark ring instead of a white one.
|
||||
const CATS = [
|
||||
{ id: 'doxa', label: 'DOXA people groups', color: '#111827' },
|
||||
{ id: 'frontier', label: 'Frontier — nothing started', color: '#e74c3c' },
|
||||
{ id: 'unreached', label: 'Unreached — work beginning', color: '#f1c40f' },
|
||||
{ id: 'reached', label: 'Reached — church is there', color: '#2ecc71' },
|
||||
{ id: 'doxa', color: '#111827' },
|
||||
{ id: 'frontier', color: '#e0392b' },
|
||||
{ id: 'unreached', color: '#e6a800' },
|
||||
{ id: 'reached', color: '#23a45a' },
|
||||
]
|
||||
const CAT_COLOR = Object.fromEntries(CATS.map(c => [c.id, c.color]))
|
||||
const counts = ref({ doxa: 0, frontier: 0, unreached: 0, reached: 0 })
|
||||
// The DOXA count is only known once the prayer API answers; until then its row says "…", not 0.
|
||||
const doxaReady = ref(false)
|
||||
|
||||
function categorize(row, doxaSet) {
|
||||
if (doxaSet.has(String(row.ROP3Code || ''))) return 'doxa'
|
||||
|
|
@ -129,7 +161,9 @@ const { map, initializeMap, destroy } = useMapInstance({
|
|||
containerRef: mapContainer,
|
||||
accessToken: mapboxToken.value,
|
||||
provider: mapProvider,
|
||||
style: profileConfig?.value?.style ?? 'ink', // named style: near-black, max pin contrast
|
||||
// Named style 'paper' = near-white minimal (Mapbox light-v11 / MapLibre positron): house style is
|
||||
// Material LIGHT, and the black DOXA pins must read on it. A host may still pass its own style.
|
||||
style: profileConfig?.value?.style ?? 'paper',
|
||||
center: [20, 12],
|
||||
zoom: 1.6,
|
||||
})
|
||||
|
|
@ -152,17 +186,60 @@ function esc(s) {
|
|||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||
}
|
||||
|
||||
async function buildFeatures() {
|
||||
const [slim, doxa] = await Promise.all([
|
||||
fetch(`${assetsBase.value}/people-groups/people-groups-slim.json`).then(r => {
|
||||
if (!r.ok) throw new Error(`people-groups-slim.json ${r.status}`)
|
||||
return r.json()
|
||||
}),
|
||||
mapData.loadData().then(() => mapData.normalizedPeopleGroups.value || []).catch((e) => {
|
||||
console.warn('[all-people-groups] DOXA overlay unavailable (map still renders by GSEC):', e)
|
||||
return []
|
||||
}),
|
||||
])
|
||||
// Rows in one shape whichever file served them: the compact points file (column arrays + string
|
||||
// dictionaries), or — for a tree built before it existed — the old slim file's objects.
|
||||
async function loadRows() {
|
||||
const base = assetsBase.value
|
||||
const r = await fetch(`${base}/people-groups/people-groups-points.json`)
|
||||
if (r.ok) {
|
||||
const d = await r.json()
|
||||
const D = d.dicts || {}
|
||||
return (d.rows || []).map((x) => ({
|
||||
lng: x[0], lat: x[1], gsec: x[2], rop3: x[3], name: x[4], country: (D.country || [])[x[5]] || '',
|
||||
population: x[6], language: (D.language || [])[x[7]] || '', religion: (D.religion || [])[x[8]] || '',
|
||||
evangelical: (D.evangelical || [])[x[9]] || '',
|
||||
}))
|
||||
}
|
||||
const old = await fetch(`${base}/people-groups/people-groups-slim.json`)
|
||||
if (!old.ok) throw new Error(`people-groups-points.json ${r.status}`)
|
||||
const slim = await old.json()
|
||||
return (slim.rows || []).map((row) => ({
|
||||
lng: Number(row.Long), lat: Number(row.Lat), gsec: row.GSEC, rop3: String(row.ROP3Code || ''),
|
||||
name: row.Name || '', country: row.CountryName || '', population: Number(row.Population) || 0,
|
||||
language: row.LanguageName || '', religion: row.ReligionName || '', evangelical: row.EvangelicalLevel || '',
|
||||
}))
|
||||
}
|
||||
|
||||
// Every pin by GSEC, from the file alone — the DOXA overlay (applyDoxa) comes later and never blocks.
|
||||
function buildFeatures(rows, doxaSet) {
|
||||
const feats = []
|
||||
const c = { doxa: 0, frontier: 0, unreached: 0, reached: 0 }
|
||||
for (const row of rows) {
|
||||
const lat = Number(row.lat), lng = Number(row.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue
|
||||
const cat = categorize({ ROP3Code: row.rop3, GSEC: row.gsec }, doxaSet)
|
||||
c[cat]++
|
||||
feats.push({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
||||
properties: {
|
||||
name: row.name, country: row.country, population: Number(row.population) || 0,
|
||||
gsec: row.gsec, evangelical: row.evangelical, language: row.language, religion: row.religion,
|
||||
rop3: String(row.rop3 || ''), category: cat,
|
||||
},
|
||||
})
|
||||
}
|
||||
counts.value = c
|
||||
featuresAll = feats
|
||||
return { type: 'FeatureCollection', features: feats }
|
||||
}
|
||||
|
||||
// The live DOXA set (rop3 = peoplegroups.org ROP3Code). Resolves to an empty set on failure.
|
||||
async function loadDoxaSet() {
|
||||
let doxa = []
|
||||
try { await mapData.loadData(); doxa = mapData.normalizedPeopleGroups.value || [] } catch (e) {
|
||||
console.warn('[all-people-groups] DOXA overlay unavailable (map still renders by GSEC):', e)
|
||||
}
|
||||
const doxaSet = new Set()
|
||||
for (const pg of doxa) {
|
||||
// The prayer API's ROP3 is imb_reg_of_people_3, which sources.json normalises to
|
||||
|
|
@ -173,28 +250,7 @@ async function buildFeatures() {
|
|||
doxaSet.add(rop)
|
||||
if (!doxaBySlugRop.has(rop)) doxaBySlugRop.set(rop, { slug: pg.slug || '', name: pg.name || '' })
|
||||
}
|
||||
const feats = []
|
||||
const c = { doxa: 0, frontier: 0, unreached: 0, reached: 0 }
|
||||
for (const row of (slim.rows || [])) {
|
||||
const lat = Number(row.Lat), lng = Number(row.Long)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) continue
|
||||
const cat = categorize(row, doxaSet)
|
||||
c[cat]++
|
||||
feats.push({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
||||
properties: {
|
||||
name: row.Name || '', country: row.CountryName || '',
|
||||
population: Number(row.Population) || 0,
|
||||
gsec: row.GSEC, evangelical: row.EvangelicalLevel || '',
|
||||
language: row.LanguageName || '', religion: row.ReligionName || '',
|
||||
rop3: String(row.ROP3Code || ''), category: cat,
|
||||
},
|
||||
})
|
||||
}
|
||||
counts.value = c
|
||||
featuresAll = feats
|
||||
return { type: 'FeatureCollection', features: feats }
|
||||
return doxaSet
|
||||
}
|
||||
|
||||
function addLayer(fc) {
|
||||
|
|
@ -219,9 +275,11 @@ function addLayer(fc) {
|
|||
'doxa', CAT_COLOR.doxa, 'frontier', CAT_COLOR.frontier,
|
||||
'unreached', CAT_COLOR.unreached, CAT_COLOR.reached],
|
||||
// Thin high-contrast ring — visible, never clunky (the pin-visibility spec).
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 0.4, 6, 0.8, 12, 1.1],
|
||||
'circle-stroke-opacity': 0.85,
|
||||
// A thin DARK ring on the light basemap: separates overlapping pins and keeps the yellow and
|
||||
// green legible on near-white (a white ring was right only on the old dark map).
|
||||
'circle-stroke-color': '#1f2937',
|
||||
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 0.3, 6, 0.6, 12, 1],
|
||||
'circle-stroke-opacity': 0.55,
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 2.2, 4, 3.4, 8, 6, 12, 9],
|
||||
'circle-opacity': 0.92,
|
||||
},
|
||||
|
|
@ -235,14 +293,14 @@ function addLayer(fc) {
|
|||
const doxaRec = p.category === 'doxa' ? doxaBySlugRop.get(p.rop3) : null
|
||||
const cat = CATS.find(x => x.id === p.category)
|
||||
const pray = doxaRec && doxaRec.slug
|
||||
? `<a href="https://pray.doxa.life/${encodeURIComponent(doxaRec.slug)}?source=doxalife&utm_source=${utmSource()}" target="_blank" rel="noopener">Pray for them →</a>`
|
||||
? `<a href="https://pray.doxa.life/${encodeURIComponent(doxaRec.slug)}?source=doxalife&utm_source=${utmSource()}" target="_blank" rel="noopener">${esc(tr('pray'))}</a>`
|
||||
: ''
|
||||
const NS = mapProvider === 'maplibre' ? window.maplibregl : window.mapboxgl
|
||||
new NS.Popup({ closeButton: true, maxWidth: '290px' })
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(`<div class="apg-pop">
|
||||
<b>${esc(p.name)}</b> <span class="cat" style="background:${cat.color}">${esc(cat.label.split(' — ')[0])}</span>
|
||||
<div class="meta">${esc(p.country)} · pop ${Number(p.population).toLocaleString()}</div>
|
||||
<b>${esc(p.name)}</b> <span class="cat" style="background:${cat.color}">${esc(tr(cat.id).split(' — ')[0])}</span>
|
||||
<div class="meta">${esc(p.country)} · ${esc(tr('population', { n: fmt(p.population) }))}</div>
|
||||
<div class="meta">${esc(p.evangelical || ('GSEC ' + p.gsec))}</div>
|
||||
<div class="meta">${esc(p.language)}${p.religion ? ' · ' + esc(p.religion) : ''}</div>
|
||||
${pray}
|
||||
|
|
@ -277,22 +335,27 @@ function selectCat(id) {
|
|||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const dataPromise = buildFeatures().catch((err) => {
|
||||
mapError.value = String(err?.message || err)
|
||||
return null
|
||||
})
|
||||
// Start both fetches NOW, in parallel with the map booting; neither waits for the other.
|
||||
const rowsPromise = loadRows().catch((err) => { mapError.value = String(err?.message || err); return null })
|
||||
const doxaPromise = loadDoxaSet()
|
||||
try { await initializeMap() } catch (err) {
|
||||
mapError.value = String(err?.message || err); return
|
||||
}
|
||||
if (!map.value) { mapError.value = 'Map failed to initialize — check token and container'; return }
|
||||
// No map: if the pane is still here that is a real failure (token, container) and says so; if it
|
||||
// was unmounted while initialising, useMapInstance returned quietly and so do we.
|
||||
if (!map.value) { if (mapContainer.value) mapError.value = 'Map failed to initialize — check token and container'; return }
|
||||
map.value.once('style.load', async () => {
|
||||
try {
|
||||
const fc = await dataPromise
|
||||
if (!fc) return
|
||||
const rows = await rowsPromise
|
||||
if (!rows || !map.value) return
|
||||
try { map.value.setProjection('mercator') } catch (_) {}
|
||||
addLayer(fc)
|
||||
addLayer(buildFeatures(rows, new Set())) // every pin, by GSEC — the first paint
|
||||
mapStore?.registerMap?.('all-people-groups', map.value)
|
||||
appReady.value = true
|
||||
// DOXA overlay: recolour when the prayer API answers, whenever that is.
|
||||
const doxaSet = await doxaPromise
|
||||
if (doxaSet.size && map.value && map.value.getSource('apg')) map.value.getSource('apg').setData(buildFeatures(rows, doxaSet))
|
||||
doxaReady.value = true
|
||||
} catch (err) { mapError.value = String(err?.message || err) }
|
||||
})
|
||||
})
|
||||
|
|
@ -308,8 +371,8 @@ const totalShown = computed(() =>
|
|||
|
||||
<template>
|
||||
<div class="apg-root">
|
||||
<div v-if="!appReady && !mapError" class="apg-loading">Loading every people group…</div>
|
||||
<div v-if="mapError" class="apg-error">The map could not be loaded. {{ mapError }}</div>
|
||||
<div v-if="!appReady && !mapError" class="apg-loading">{{ tr('loading') }}</div>
|
||||
<div v-if="mapError" class="apg-error">{{ tr('loadError') }} {{ mapError }}</div>
|
||||
<div ref="mapContainer" class="apg-canvas" />
|
||||
|
||||
<!-- SHARING. This bundle was published, embeddable and reachable with NO way to
|
||||
|
|
@ -331,18 +394,18 @@ const totalShown = computed(() =>
|
|||
</MapToolbar>
|
||||
|
||||
<div v-if="appReady" class="apg-legend">
|
||||
<div class="apg-legend-title">All People Groups</div>
|
||||
<div class="apg-legend-sub">{{ totalShown.toLocaleString() }} group–country locations</div>
|
||||
<div class="apg-legend-title">{{ tr('title') }}</div>
|
||||
<div class="apg-legend-sub">{{ tr('locations', { n: fmt(totalShown) }) }}</div>
|
||||
<div
|
||||
v-for="c in CATS" :key="c.id"
|
||||
class="apg-row" :class="{ active: activeCat === c.id }"
|
||||
@click="selectCat(c.id)"
|
||||
>
|
||||
<span class="apg-dot" :style="{ background: c.color }"></span>
|
||||
{{ c.label }}
|
||||
<span class="apg-count">{{ (counts[c.id] || 0).toLocaleString() }}</span>
|
||||
{{ tr(c.id) }}
|
||||
<span class="apg-count">{{ c.id === 'doxa' && !doxaReady ? '…' : fmt(counts[c.id] || 0) }}</span>
|
||||
</div>
|
||||
<div class="apg-hint">Click a row to see all of them · click again for everything</div>
|
||||
<div class="apg-hint">{{ tr('hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
# How the Vision Card's numbers are counted
|
||||
|
||||
**Every number on the card is computed from data. None is typed in.**
|
||||
`library/scripts/people-groups/build-vision-funnel.mjs` writes them to
|
||||
`data/people-groups/vision-funnel.json`, and the card compiles that file in.
|
||||
|
||||
| Step | Rule | Source field |
|
||||
|---|---|---|
|
||||
| **People groups** | Every row of the IMB people-group data: one people group in one country | all rows of `people_groups.csv` |
|
||||
| **Unreached** | Less than 2% evangelical | IMB `LostnessPriority` 0–2 (Frontier, Pioneer, Expanding Unreached) |
|
||||
| **Frontier** | Less than 0.1% evangelical, a subset of the unreached | IMB `LostnessPriority` 0 ("Frontier Unreached People Group") |
|
||||
| **DOXA people groups** | Every group on DOXA's prayer list, matched to the IMB row by people (ROP3) and country | `doxa-groups-snapshot.json`, saved from `pray.doxa.life/api/people-groups/list` |
|
||||
|
||||
**The first three steps nest:** frontier ⊂ unreached ⊂ all. **DOXA groups do not nest inside
|
||||
frontier.** Most are frontier, but not all, so the card states how many are ("1,745 of them are
|
||||
frontier") instead of drawing DOXA as a slice of frontier.
|
||||
|
||||
**Populations** sum the IMB `Population` of each step's rows. For DOXA groups, that is the rows the
|
||||
list matched; the few list entries with no IMB row count as groups but add no population.
|
||||
|
||||
**Why the total is not 16–17 thousand.** That figure counts people groups the way Joshua Project
|
||||
does. This card uses the IMB (peoplegroups.org) dataset that ships in this repository, which lists
|
||||
12,379 people groups by country. doxa.life's own "Who are the unengaged?" funnel uses its own
|
||||
research data, so its figures differ slightly from these.
|
||||
|
||||
**To refresh:** replace `data/people-groups/people_groups.csv`, then run
|
||||
`node library/scripts/people-groups/build-vision-funnel.mjs` (add `--offline` to keep the saved
|
||||
DOXA list), then rebuild the bundle. The card shows the date of both sources.
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
{
|
||||
"_comment": "Preconfigured instances of the vision-card element. provider:'maplibre' flags the generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so it mounts the element INSTANTLY with no Mapbox token ladder — correct for a self-contained single element that uses no map at all.",
|
||||
"_comment": "Preconfigured instances of the vision-card element. provider:'maplibre' flags the generated standalone page as TOKEN-FREE (see internal/generate-tree.mjs) so it mounts the element INSTANTLY with no Mapbox token ladder — correct for a self-contained single element that uses no map at all. The card's numbers are computed from data (see HOW-THE-NUMBERS-ARE-COUNTED.md); an instance may set title, tagline, accent or locale, never numbers.",
|
||||
"instances": [
|
||||
{
|
||||
"id": "default",
|
||||
"title": "Vision card — reach at a glance",
|
||||
"props": { "profile": "doxa-vision-card", "provider": "maplibre", "instanceId": "vision-card-default" }
|
||||
"props": {
|
||||
"profile": "doxa-vision-card",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "vision-card-default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "frontier",
|
||||
|
|
@ -15,12 +19,7 @@
|
|||
"instanceId": "vision-card-frontier",
|
||||
"accent": "#22d3ee",
|
||||
"title": "The last frontier of the gospel.",
|
||||
"tagline": "Frontier people groups — where fewer than one in a thousand follow Jesus.",
|
||||
"stats": [
|
||||
{ "value": "1,910", "label": "Frontier groups", "note": "under 0.1% believers" },
|
||||
{ "value": "2.1B", "label": "People", "note": "living in frontier settings" },
|
||||
{ "value": "0.1%", "label": "Christian", "note": "or fewer, in each group" }
|
||||
]
|
||||
"tagline": "Frontier people groups: fewer than one person in a thousand is an evangelical believer."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,240 +2,163 @@
|
|||
/**
|
||||
* doxa-vision-card.vue — Application Profile: Vision Card (Capability-1 showcase)
|
||||
*
|
||||
* @description The Capability-1 example: ONE folder -> ONE bundle -> ONE element.
|
||||
* A single, self-contained, genuinely beautiful info element — a
|
||||
* "mission vision card" that rotates through a small set of reach
|
||||
* stats over an animated aurora backdrop. NO map, NO Mapbox token,
|
||||
* NO library map-infra: it renders INSTANTLY on any page, embedded
|
||||
* via iframe OR direct-JS, which is exactly what the lightest
|
||||
* capability tier is meant to demonstrate.
|
||||
* @description A true funnel of the remaining task, every number computed from data: all people
|
||||
* groups → unreached → frontier → the DOXA people groups. One self-contained element,
|
||||
* no map, no token, no network: it renders instantly, embedded by iframe or script.
|
||||
* @profile doxa-vision-card
|
||||
* @bundle doxa-vision-card
|
||||
* @element doxa-vision-card
|
||||
*
|
||||
* This is a PURE single element: it is registered directly with
|
||||
* defineCustomElement (see index.js) rather than through the map ProfileLoader,
|
||||
* so it carries no nested SPA, no router and no remote data dependency. It reads
|
||||
* the standard `profile-config` attribute only to accept optional overrides
|
||||
* (title, tagline, custom stat cards, accent color) — with sensible defaults it
|
||||
* needs nothing at all.
|
||||
* ★ WHY IT WAS REBUILT (2026-09-25). The Driver: "the data doesn't make sense … super false
|
||||
* information." The old card rotated four TYPED-IN figures (7,398 / 3,240 / 42% / 1,910) with no
|
||||
* source. Now NO number is written here. They come from data/people-groups/vision-funnel.json,
|
||||
* which library/scripts/people-groups/build-vision-funnel.mjs computes from the IMB people-group
|
||||
* CSV in the repo and a saved snapshot of the live DOXA list, and which is compiled in at build
|
||||
* time. The rules are in HOW-THE-NUMBERS-ARE-COUNTED.md beside this file, and in the card's own
|
||||
* "How these are counted" note.
|
||||
*
|
||||
* ★ A TRUE FUNNEL. Bar widths are proportional to the counts, and the first three steps NEST (each
|
||||
* is a subset of the one above). The DOXA list does not nest inside frontier, so the card says how
|
||||
* many DOXA groups are frontier instead of drawing them as a sub-slice of it.
|
||||
*
|
||||
* Registered directly with defineCustomElement (index.js), not through the map ProfileLoader. It
|
||||
* reads `profile-config` for optional overrides: title, tagline, accent, locale.
|
||||
*/
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import FUNNEL from '../../data/people-groups/vision-funnel.json'
|
||||
|
||||
// Every locale's strings, compiled in (library/i18n/locales/<lc>/vision.json, held to key parity by
|
||||
// check:i18n-parity). Looked up per key: locale → English → the key itself.
|
||||
const STRINGS = {}
|
||||
for (const [path, mod] of Object.entries(import.meta.glob('../../library/i18n/locales/*/vision.json', { eager: true, import: 'default' }))) {
|
||||
const m = path.match(/locales\/([^/]+)\/vision\.json$/)
|
||||
if (m) STRINGS[m[1]] = mod
|
||||
}
|
||||
|
||||
// ─── profile-config prop (optional overrides) ────────────────────────────────
|
||||
// defineCustomElement passes HTML attributes as props. We accept the same
|
||||
// `profile-config` JSON attribute the rest of the bundler uses, so the studio's
|
||||
// generated pages (which stamp a profile-config) render us with no extra wiring.
|
||||
const props = defineProps({
|
||||
profileConfig: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const config = computed(() => {
|
||||
if (!props.profileConfig) return {}
|
||||
try { return JSON.parse(props.profileConfig) || {} }
|
||||
catch { return {} }
|
||||
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
|
||||
})
|
||||
|
||||
const accent = computed(() => config.value.accent || '#8b5cf6')
|
||||
const heading = computed(() => config.value.title || 'Every people. Every place.')
|
||||
const tagline = computed(() => config.value.tagline || 'A living picture of the unreached — one light for every group still waiting.')
|
||||
|
||||
// Default rotating stats. A host may override with profile-config.stats:
|
||||
// [{ value, label, note }]
|
||||
const DEFAULT_STATS = [
|
||||
{ value: '7,398', label: 'People groups', note: 'catalogued worldwide' },
|
||||
{ value: '3,240', label: 'Still unreached', note: 'no indigenous church movement' },
|
||||
{ value: '42%', label: 'Of the world', note: 'lives beyond a gospel witness' },
|
||||
{ value: '1,910', label: 'Frontier groups', note: 'under 0.1% believers' },
|
||||
]
|
||||
const stats = computed(() => {
|
||||
const s = config.value.stats
|
||||
return Array.isArray(s) && s.length ? s : DEFAULT_STATS
|
||||
const locale = computed(() => {
|
||||
const raw = String(config.value.locale || (typeof document !== 'undefined' && document.documentElement.lang) || 'en')
|
||||
const base = raw.split('-')[0].toLowerCase()
|
||||
return STRINGS[raw] ? raw : (STRINGS[base] ? base : 'en')
|
||||
})
|
||||
|
||||
// ─── Rotation ────────────────────────────────────────────────────────────────
|
||||
const idx = ref(0)
|
||||
const active = computed(() => stats.value[idx.value % stats.value.length])
|
||||
let timer = null
|
||||
|
||||
function go(i) {
|
||||
idx.value = ((i % stats.value.length) + stats.value.length) % stats.value.length
|
||||
restart()
|
||||
}
|
||||
function next() { idx.value = (idx.value + 1) % stats.value.length }
|
||||
function restart() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = setInterval(next, 4200)
|
||||
const dir = computed(() => (locale.value === 'ar' ? 'rtl' : 'ltr'))
|
||||
function t(key, vars) {
|
||||
let s = (STRINGS[locale.value] || {})[key] || (STRINGS.en || {})[key] || key
|
||||
if (vars) for (const k in vars) s = s.split('{' + k + '}').join(String(vars[k]))
|
||||
return s
|
||||
}
|
||||
const num = (n) => new Intl.NumberFormat(locale.value).format(n)
|
||||
const people = (n) => new Intl.NumberFormat(locale.value, { notation: 'compact', maximumFractionDigits: 1 }).format(n)
|
||||
const date = (iso) => { try { return new Intl.DateTimeFormat(locale.value, { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(iso + 'T00:00:00Z')) } catch { return iso } }
|
||||
|
||||
onMounted(restart)
|
||||
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||
const accent = computed(() => config.value.accent || '#92b195')
|
||||
const heading = computed(() => config.value.title || t('title'))
|
||||
const tagline = computed(() => config.value.tagline || t('tagline'))
|
||||
|
||||
const top = FUNNEL.steps[0].groups
|
||||
const steps = computed(() => FUNNEL.steps.map((s) => ({
|
||||
id: s.id,
|
||||
groups: num(s.groups),
|
||||
population: t('people', { n: people(s.population) }),
|
||||
label: t(s.id + 'Label'),
|
||||
note: s.id === 'doxa' ? t('doxaNote', { frontier: num(s.frontier) }) : t(s.id + 'Note'),
|
||||
// Proportional, with a floor so the smallest bar still holds its number.
|
||||
width: Math.max(24, Math.round((s.groups / top) * 100)),
|
||||
})))
|
||||
const rules = computed(() => ['ruleAll', 'ruleUnreached', 'ruleFrontier', 'ruleDoxa'].map((k) => t(k)))
|
||||
const sources = computed(() => t('sources', {
|
||||
imb: FUNNEL.sources.imb.publisher, date: date(FUNNEL.sources.imb.captured), date2: date(FUNNEL.sources.doxa.fetched),
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vc-root" :style="{ '--accent': accent }">
|
||||
<!-- Animated aurora backdrop -->
|
||||
<div class="vc-aurora" aria-hidden="true">
|
||||
<span class="vc-blob vc-blob-a"></span>
|
||||
<span class="vc-blob vc-blob-b"></span>
|
||||
<span class="vc-blob vc-blob-c"></span>
|
||||
</div>
|
||||
<div class="vc-grain" aria-hidden="true"></div>
|
||||
|
||||
<div class="vc-content">
|
||||
<div class="vc-kicker">
|
||||
<span class="vc-pulse"></span>
|
||||
Doxa · Vision
|
||||
</div>
|
||||
|
||||
<div class="vc-root" :dir="dir" :lang="locale" :style="{ '--accent': accent }">
|
||||
<article class="vc-card">
|
||||
<div class="vc-kicker"><span class="vc-mark" aria-hidden="true"></span>{{ t('kicker') }}</div>
|
||||
<h1 class="vc-title">{{ heading }}</h1>
|
||||
<p class="vc-tagline">{{ tagline }}</p>
|
||||
|
||||
<!-- The rotating stat -->
|
||||
<div class="vc-stat" :key="idx">
|
||||
<div class="vc-stat-value">{{ active.value }}</div>
|
||||
<div class="vc-stat-label">{{ active.label }}</div>
|
||||
<div v-if="active.note" class="vc-stat-note">{{ active.note }}</div>
|
||||
</div>
|
||||
<ol class="vc-funnel" :aria-label="t('funnelLabel')">
|
||||
<li v-for="(s, i) in steps" :key="s.id" class="vc-step" :class="'vc-step-' + s.id">
|
||||
<div class="vc-bar-row">
|
||||
<div class="vc-bar" :style="{ width: s.width + '%', '--i': i }">
|
||||
<span class="vc-n">{{ s.groups }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vc-caption">
|
||||
<span class="vc-label">{{ s.label }}</span>
|
||||
<span class="vc-note">{{ s.note }} · {{ s.population }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Dots -->
|
||||
<div class="vc-dots" role="tablist" aria-label="Vision stats">
|
||||
<button
|
||||
v-for="(s, i) in stats"
|
||||
:key="i"
|
||||
class="vc-dot"
|
||||
:class="{ 'is-active': i === idx }"
|
||||
type="button"
|
||||
:aria-label="s.label"
|
||||
@click="go(i)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
<details class="vc-how">
|
||||
<summary>{{ t('howCounted') }}</summary>
|
||||
<ul><li v-for="(r, i) in rules" :key="i">{{ r }}</li></ul>
|
||||
</details>
|
||||
<p class="vc-source">{{ sources }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* defineCustomElement injects this root <style> into the element's shadow DOM. */
|
||||
/* defineCustomElement injects this root <style> into the element's shadow DOM.
|
||||
House style: Material LIGHT — #f3f3f1 surface, #fff card, #3b463d text, #92b195 accent,
|
||||
#d1d4cd border, #6d766e muted. No dark backdrop (the old aurora card was near-black). */
|
||||
:host { display: block; }
|
||||
.vc-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(140% 120% at 20% 0%, #141a2e 0%, #0a0e18 55%, #05070d 100%);
|
||||
color: #eef2fb;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
position: absolute; inset: 0; overflow: auto;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding: 24px 16px; box-sizing: border-box;
|
||||
background: #f3f3f1; color: #3b463d;
|
||||
font-family: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* ── Aurora blobs ── */
|
||||
.vc-aurora { position: absolute; inset: -20%; z-index: 0; filter: blur(60px); opacity: .85; }
|
||||
.vc-blob { position: absolute; width: 46%; height: 46%; border-radius: 50%; mix-blend-mode: screen; }
|
||||
.vc-blob-a { background: var(--accent); top: -6%; left: 8%; animation: vc-drift-a 16s ease-in-out infinite; }
|
||||
.vc-blob-b { background: #22d3ee; bottom: -10%; right: 4%; animation: vc-drift-b 20s ease-in-out infinite; }
|
||||
.vc-blob-c { background: #f472b6; top: 30%; right: 30%; width: 34%; height: 34%; animation: vc-drift-c 24s ease-in-out infinite; }
|
||||
|
||||
@keyframes vc-drift-a { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(8%, 10%) scale(1.15); } }
|
||||
@keyframes vc-drift-b { 0%,100% { transform: translate(0,0) scale(1.05); } 50% { transform: translate(-10%,-8%) scale(.9); } }
|
||||
@keyframes vc-drift-c { 0%,100% { transform: translate(0,0) scale(1); } 50% { transform: translate(-6%, 12%) scale(1.2); } }
|
||||
|
||||
/* Subtle grain / vignette so text stays crisp over the aurora */
|
||||
.vc-grain {
|
||||
position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(120% 100% at 50% 40%, transparent 40%, rgba(3,5,10,.55) 100%);
|
||||
.vc-card {
|
||||
width: 100%; max-width: 640px; box-sizing: border-box;
|
||||
background: #fff; border: 1px solid #d1d4cd; border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(59,70,61,.06), 0 8px 24px -12px rgba(59,70,61,.18);
|
||||
padding: 28px 28px 20px;
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.vc-content {
|
||||
position: relative; z-index: 2;
|
||||
width: min(88%, 560px);
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
animation: vc-rise .7s cubic-bezier(.16,1,.3,1) both;
|
||||
}
|
||||
@keyframes vc-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.vc-kicker {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
|
||||
color: #c7d2fe;
|
||||
padding: 6px 14px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.vc-pulse {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0 var(--accent);
|
||||
animation: vc-pulse 2.4s ease-out infinite;
|
||||
}
|
||||
@keyframes vc-pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent); }
|
||||
70% { box-shadow: 0 0 0 10px transparent; }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #6d766e;
|
||||
}
|
||||
.vc-mark { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.vc-title { margin: 10px 0 6px; font-size: clamp(22px, 4.2vw, 30px); line-height: 1.15; font-weight: 800; color: #3b463d; }
|
||||
.vc-tagline { margin: 0 0 22px; font-size: 15px; line-height: 1.5; color: #6d766e; }
|
||||
|
||||
.vc-title {
|
||||
margin: 20px 0 10px;
|
||||
font-size: clamp(24px, 5.2vw, 40px);
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #c7cde6 100%);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent; color: transparent;
|
||||
}
|
||||
.vc-tagline {
|
||||
margin: 0 auto 26px;
|
||||
max-width: 46ch;
|
||||
font-size: clamp(13px, 2vw, 15px);
|
||||
line-height: 1.6;
|
||||
color: #aab4d4;
|
||||
.vc-funnel { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.vc-bar-row { display: flex; justify-content: center; }
|
||||
.vc-bar {
|
||||
box-sizing: border-box; min-height: 44px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0 10px;
|
||||
background: var(--accent); color: #1d2a1f;
|
||||
animation: vc-grow .7s cubic-bezier(.16,1,.3,1) both; animation-delay: calc(var(--i) * 90ms);
|
||||
transform-origin: center;
|
||||
}
|
||||
/* The funnel darkens as it narrows: the last step is the assignment. */
|
||||
.vc-step-all .vc-bar { background: #dfe7df; }
|
||||
.vc-step-unreached .vc-bar { background: #c3d5c4; }
|
||||
.vc-step-frontier .vc-bar { background: #a8c3aa; }
|
||||
.vc-step-doxa .vc-bar { background: #3b463d; color: #fff; }
|
||||
.vc-n { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.vc-caption { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-top: 6px; text-align: center; }
|
||||
.vc-label { font-size: 14px; font-weight: 700; color: #3b463d; }
|
||||
.vc-note { font-size: 12.5px; color: #6d766e; }
|
||||
@keyframes vc-grow { from { transform: scaleX(.2); opacity: 0; } to { transform: scaleX(1); opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .vc-bar { animation: none; } }
|
||||
|
||||
/* ── Rotating stat ── */
|
||||
.vc-stat {
|
||||
margin: 0 auto 22px;
|
||||
padding: 22px 20px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
box-shadow: 0 20px 50px -24px rgba(0,0,0,.7);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: vc-swap .5s cubic-bezier(.16,1,.3,1) both;
|
||||
}
|
||||
@keyframes vc-swap { from { opacity: 0; transform: translateY(8px) scale(.985); } to { opacity: 1; transform: none; } }
|
||||
.vc-stat-value {
|
||||
font-size: clamp(38px, 9vw, 64px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 32px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
.vc-stat-label {
|
||||
margin-top: 10px;
|
||||
font-size: 14px; font-weight: 700; letter-spacing: .04em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.vc-stat-note { margin-top: 4px; font-size: 12px; color: #8b97b8; }
|
||||
|
||||
/* ── Dots ── */
|
||||
.vc-dots { display: flex; gap: 8px; justify-content: center; }
|
||||
.vc-dot {
|
||||
width: 8px; height: 8px; padding: 0; border: none; cursor: pointer;
|
||||
border-radius: 999px; background: rgba(255,255,255,.22);
|
||||
transition: background .2s, width .2s, transform .2s;
|
||||
}
|
||||
.vc-dot:hover { transform: scale(1.25); background: rgba(255,255,255,.4); }
|
||||
.vc-dot.is-active { width: 22px; background: var(--accent); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vc-blob, .vc-content, .vc-stat, .vc-pulse { animation: none !important; }
|
||||
}
|
||||
.vc-how { margin-top: 22px; border-top: 1px solid #e6e8e3; padding-top: 12px; font-size: 13px; color: #3b463d; }
|
||||
.vc-how summary { cursor: pointer; font-weight: 700; color: #3b463d; }
|
||||
.vc-how summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
.vc-how ul { margin: 8px 0 0; padding-inline-start: 18px; line-height: 1.55; color: #3b463d; }
|
||||
.vc-source { margin: 12px 0 0; font-size: 11.5px; line-height: 1.5; color: #6d766e; }
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
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 <all-people-groups> custom element. -->
|
||||
<script src="../all-people-groups.js" integrity="sha384-PdhYGEpsIkZqdmz3j4UfWv2MyK9z2BT5xs1vDu1DxLCMifYoUe2iZWy7evMgsYox" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-lJI0V+GhEdU/pZEFL7cQwTcYVqmzPyjApsqQqkXnhmk9hB1zKVzd2het4xGMdWxx" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
1
public/doxa-maps/assets/people-groups/people-groups-points.json
generated
Normal file
1
public/doxa-maps/assets/people-groups/people-groups-points.json
generated
Normal file
File diff suppressed because one or more lines are too long
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 <doxa-vision-card> custom element. -->
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-2mza3cltN5LQQIrlENG/IVL6Icd9zzcFve3E8R8VWuCdYOGW9uGKFvxcbu6eFfwn" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" 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 <doxa-vision-card> custom element. -->
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-2mza3cltN5LQQIrlENG/IVL6Icd9zzcFve3E8R8VWuCdYOGW9uGKFvxcbu6eFfwn" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-vision-card","provider":"maplibre","instanceId":"vision-card-frontier","accent":"#22d3ee","title":"The last frontier of the gospel.","tagline":"Frontier people groups — where fewer than one in a thousand follow Jesus.","stats":[{"value":"1,910","label":"Frontier groups","note":"under 0.1% believers"},{"value":"2.1B","label":"People","note":"living in frontier settings"},{"value":"0.1%","label":"Christian","note":"or fewer, in each group"}]};
|
||||
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-vision-card","provider":"maplibre","instanceId":"vision-card-frontier","accent":"#22d3ee","title":"The last frontier of the gospel.","tagline":"Frontier people groups: fewer than one person in a thousand is an evangelical believer."};
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-vision-card> custom element. -->
|
||||
<script src="../doxa-vision-card.js" integrity="sha384-2mza3cltN5LQQIrlENG/IVL6Icd9zzcFve3E8R8VWuCdYOGW9uGKFvxcbu6eFfwn" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-vision-card.js" integrity="sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9" 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
10
public/doxa-maps/manifest.json
generated
10
public/doxa-maps/manifest.json
generated
|
|
@ -13,7 +13,7 @@
|
|||
"bundle": "all-people-groups",
|
||||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"output": "doxa-maps/all-people-groups/all-people-groups.js",
|
||||
"integrity": "sha384-PdhYGEpsIkZqdmz3j4UfWv2MyK9z2BT5xs1vDu1DxLCMifYoUe2iZWy7evMgsYox",
|
||||
"integrity": "sha384-lJI0V+GhEdU/pZEFL7cQwTcYVqmzPyjApsqQqkXnhmk9hB1zKVzd2het4xGMdWxx",
|
||||
"elements": [
|
||||
"all-people-groups"
|
||||
],
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/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=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-PdhYGEpsIkZqdmz3j4UfWv2MyK9z2BT5xs1vDu1DxLCMifYoUe2iZWy7evMgsYox\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-lJI0V+GhEdU/pZEFL7cQwTcYVqmzPyjApsqQqkXnhmk9hB1zKVzd2het4xGMdWxx\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
},
|
||||
"bundle": "all-people-groups"
|
||||
}
|
||||
|
|
@ -493,7 +493,7 @@
|
|||
"bundle": "doxa-vision-card",
|
||||
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
|
||||
"output": "doxa-maps/doxa-vision-card/doxa-vision-card.js",
|
||||
"integrity": "sha384-2mza3cltN5LQQIrlENG/IVL6Icd9zzcFve3E8R8VWuCdYOGW9uGKFvxcbu6eFfwn",
|
||||
"integrity": "sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9",
|
||||
"elements": [
|
||||
"doxa-vision-card"
|
||||
],
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
"scriptUrl": "./doxa-vision-card/doxa-vision-card.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-vision-card/doxa-vision-card/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-vision-card/doxa-vision-card.js\" integrity=\"sha384-2mza3cltN5LQQIrlENG/IVL6Icd9zzcFve3E8R8VWuCdYOGW9uGKFvxcbu6eFfwn\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-vision-card/doxa-vision-card.js\" integrity=\"sha384-/oOxfonW4sXJgaXVJgZOZb1R+3gbePfTP1Wh2irBX2KnDCsKjv1YUnzbyheKJzZ9\" crossorigin=\"anonymous\"></script>\n<doxa-vision-card profile-config='{\"profile\":\"doxa-vision-card\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-vision-card>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -537,7 +537,7 @@
|
|||
"instanceId": "vision-card-frontier",
|
||||
"accent": "#22d3ee",
|
||||
"title": "The last frontier of the gospel.",
|
||||
"tagline": "Frontier people groups — where fewer than one in a thousand follow Jesus.",
|
||||
"tagline": "Frontier people groups: fewer than one person in a thousand is an evangelical believer.",
|
||||
"stats": [
|
||||
{
|
||||
"value": "1,910",
|
||||
|
|
|
|||
Loading…
Reference in a new issue