Gospel x Internet: the language tree and the gospel resources tab

Every language family of the board's people groups, drawn as a radial tree
(family, branches, languages, dialects) on a whiteboard, the families
packed like cells in a dish with no overlaps. Green: a New Testament or
full Bible found in the language; yellow: something found but no New
Testament; red: nothing found. A family shows the worst colour inside it;
a dialect carries its language's colour, lighter, because every source
catalogues resources by language. Click a circle to narrow the board to
it. The table under the centre gains a Gospel resources tab listing every
screened link for the languages in scope.

The tree is Glottolog 5.2.1 (CC BY 4.0), snapshotted to the languoids the
roster needs: 833 of 864 codes, the 31 IMB private codes kept under their
own node. The layout is computed at build time and drawn by the same map
engine the board already loads, so zooming out draws less and labels
never overlap.

check:gospel-internet now also measures the tree (complete, coloured by
its rule, no overlapping families) and server-renders every panel with the
real data in en and ar: profiles-mount never renders a profile's children,
and a throw planted in a panel passed it.
This commit is contained in:
Doxa Maps 2026-09-27 02:24:52 +00:00
parent ead8afa817
commit b125278f8a
30 changed files with 1960 additions and 109 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -89,7 +89,7 @@
"check:gallery-embed:live-cors": "node internal/check-research-gallery-embed.mjs --live-cors",
"check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre",
"check:gospel-all": "node internal/check-gospel-all.mjs",
"check:gospel-internet": "node plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-i18n.mjs",
"check:gospel-internet": "node plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-i18n.mjs && node plugin-designs/gospel-internet/scripts/check-language-tree.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs",
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
"check:embed-contract": "node internal/check-gospel-embed-contract.mjs",
"check:engine-contract": "node internal/check-gospel-engine-contract.mjs",

View file

@ -32,10 +32,16 @@ import GiMap from './src/components/GiMap.vue'
import GiCharts from './src/components/GiCharts.vue'
import GiGrid from './src/components/GiGrid.vue'
import GiDrawer from './src/components/GiDrawer.vue'
import GiTree from './src/components/GiTree.vue'
import GiResources from './src/components/GiResources.vue'
const T = useGiT()
const board = useBoard()
const { bus } = board
// The URL / host preset (view, facets, centre panel, group) is applied BEFORE the first render, so the
// board opens already in its state: no flash of the default view, and the first render mounts the panel
// the preset asks for (the language tree included) where a headless mount check can see it.
board.readUrl()
// ── responsive: one panel at a time under 760px (ResizeObserver on the shell, not @container: a
// container query cannot target the container itself, which is unreliable inside a shadow root) ──────
@ -48,7 +54,6 @@ onMounted(() => {
ro = new ResizeObserver(([e]) => { isNarrow.value = e.contentRect.width < 760 })
ro.observe(shell.value)
}
board.readUrl()
board.load()
})
onBeforeUnmount(() => { try { ro && ro.disconnect() } catch (_) {} })
@ -81,6 +86,20 @@ const MOBILE_TABS = computed(() => [
{ id: 'grid', label: T('gospelInternet.panel.grid', 'Groups') },
{ id: 'charts', label: T('gospelInternet.panel.charts', 'Charts') },
])
// The centre shows the geographic map or the language tree; the tree (its own whiteboard map) is built
// on first use and then kept, so switching back and forth costs nothing after the first time.
const treeEver = ref(false)
watch(() => board.centre.value, (c) => { if (c === 'tree') treeEver.value = true }, { immediate: true })
const CENTRE_TABS = computed(() => [
{ id: 'map', label: T('gospelInternet.centre.map', 'Map') },
{ id: 'tree', label: T('gospelInternet.centre.tree', 'Language tree') },
])
// the table under the centre: the people groups, or the gospel resources for their languages
const tableTab = ref('groups')
const TABLE_TABS = computed(() => [
{ id: 'groups', label: T('gospelInternet.table.groups', 'People groups') },
{ id: 'resources', label: T('gospelInternet.table.resources', 'Gospel resources') },
])
const scoped = computed(() => Object.keys(bus.filterSet).length > 0 || !!bus.searchTerm.value)
function resetAll() { board.setView('board'); bus.setSearch(''); bus.clearSelection(); board.closeGroup() }
@ -123,7 +142,16 @@ useShadowStyles(
'.gb-facets{grid-area:facets;min-height:0;border-inline-end:1px solid #d1d4cd}' +
'.gb-map{grid-area:map;min-height:0;position:relative}' +
'.gb-rail{grid-area:rail;min-height:0;border-inline-start:1px solid #d1d4cd}' +
'.gb-grid{grid-area:grid;min-height:0;border-top:1px solid #d1d4cd}' +
'.gb-grid{grid-area:grid;min-height:0;border-top:1px solid #d1d4cd;display:flex;flex-direction:column}' +
'.gb-table-body{flex:1;min-height:0}' +
'.gb-table-body > *{height:100%}' +
'.gb-map > *:not(.gb-seg){position:absolute;inset:0}' +
'.gb-seg{display:inline-flex;gap:2px;padding:2px;border:1px solid #d1d4cd;border-radius:8px;background:#fff}' +
'.gb-seg button{border:0;background:none;color:#3b463d;border-radius:6px;padding:4px 12px;min-height:26px;font:inherit;font-size:12.5px;cursor:pointer}' +
'.gb-seg button[aria-pressed="true"]{background:#3b463d;color:#fff}' +
'.gb-seg button:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
'.gb-centre-tabs{position:absolute;inset-block-start:10px;left:50%;transform:translateX(-50%);z-index:8;box-shadow:0 2px 8px rgba(0,0,0,.10)}' +
'.gb-table-tabs{margin:6px 10px 0;align-self:flex-start}' +
'.gb-state{position:absolute;inset:0;display:grid;place-items:center;z-index:40;background:rgba(243,243,241,.7);font-size:14px;color:#6d766e;pointer-events:none}' +
'.gb-state.err{color:#7d3b30;pointer-events:auto}' +
'.gb-rz{position:relative;z-index:6;touch-action:none}' +
@ -178,9 +206,25 @@ useShadowStyles(
</div>
<div v-show="!isNarrow || mobilePanel === 'facets'" class="gb-facets"><GiFacetRail /></div>
<div v-show="!isNarrow || mobilePanel === 'map'" class="gb-map"><GiMap /></div>
<div v-show="!isNarrow || mobilePanel === 'map'" class="gb-map">
<GiMap v-show="board.centre.value === 'map'" />
<GiTree v-if="treeEver" v-show="board.centre.value === 'tree'" />
<div class="gb-seg gb-centre-tabs" role="group" :aria-label="T('gospelInternet.centre.aria', 'Show')">
<button v-for="c in CENTRE_TABS" :key="c.id" type="button" :aria-pressed="board.centre.value === c.id ? 'true' : 'false'"
:data-centre-tab="c.id" @click="board.setCentre(c.id)">{{ c.label }}</button>
</div>
</div>
<div v-show="!isNarrow || mobilePanel === 'charts'" class="gb-rail"><GiCharts /></div>
<div v-show="!isNarrow || mobilePanel === 'grid'" class="gb-grid"><GiGrid /></div>
<div v-show="!isNarrow || mobilePanel === 'grid'" class="gb-grid">
<div class="gb-seg gb-table-tabs" role="tablist" :aria-label="T('gospelInternet.table.aria', 'Table')">
<button v-for="t in TABLE_TABS" :key="t.id" type="button" role="tab" :aria-selected="tableTab === t.id ? 'true' : 'false'"
:aria-pressed="tableTab === t.id ? 'true' : 'false'" :data-table-tab="t.id" @click="tableTab = t.id">{{ t.label }}</button>
</div>
<div class="gb-table-body">
<GiGrid v-show="tableTab === 'groups'" />
<GiResources v-if="tableTab === 'resources'" />
</div>
</div>
<template v-if="!isNarrow">
<div class="gb-rz gb-rz-facet" role="separator" aria-orientation="vertical" tabindex="0"

View file

@ -1,9 +1,58 @@
{
"_comment": "Preset partner embeds of <gospel-internet-board> (SPEC-GOSPEL-INTERNET-DASHBOARD section 14). Keyless: provider 'auto' falls to MapLibre with no token. Each preset is a URL a partner can QR.",
"instances": [
{ "id": "board", "title": "Gospel x Internet — the board", "props": { "profile": "board", "urlState": "query" } },
{ "id": "zero", "title": "Nothing in their own language — the front door", "props": { "profile": "board", "view": "zero", "urlState": "query" } },
{ "id": "india-china", "title": "India and China by state and province", "props": { "profile": "board", "view": "province", "urlState": "query" } },
{ "id": "project-700", "title": "Project 700 — the list as a partner embed", "props": { "profile": "board", "list": "700", "urlState": "query" } }
{
"id": "board",
"title": "Gospel x Internet — the board",
"props": {
"profile": "board",
"urlState": "query"
}
},
{
"id": "zero",
"title": "Nothing in their own language — the front door",
"props": {
"profile": "board",
"view": "zero",
"urlState": "query"
}
},
{
"id": "india-china",
"title": "India and China by state and province",
"props": {
"profile": "board",
"view": "province",
"urlState": "query"
}
},
{
"id": "language-tree",
"title": "Language tree — which languages have the gospel, family by family",
"props": {
"profile": "board",
"panel": "tree",
"urlState": "query"
}
},
{
"id": "platforms",
"title": "Platforms — which apps would carry a resource there",
"props": {
"profile": "board",
"view": "platforms",
"urlState": "query"
}
},
{
"id": "project-700",
"title": "Project 700 — the list as a partner embed",
"props": {
"profile": "board",
"list": "700",
"urlState": "query"
}
}
]
}

View file

@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* build-language-tree.mjs — the LANGUAGE TREE the board draws on its whiteboard: every language family
* our people groups belong to, as a radial tree (family -> branches -> languages -> dialects), packed with
* the other families into one non-overlapping tapestry, coloured by what the gospel sources hold.
*
* LAYOUT IS COMPUTED HERE, ONCE, so a phone does no layout work. Each family is a radial tree (branches
* fanned around its root, one angular slot per leaf, radius by depth, the ring wide enough for its leaves).
* Each tree's enclosing circle is then packed against the others, largest first, at the nearest free spot
* on a spiral: circles never overlap, and the whole fills a roughly round board. Coordinates are written as
* lng/lat through the INVERSE of Web Mercator, so on the map engine the drawing is undistorted.
*
* COLOUR, PER LANGUAGE (ISO 639-3), from sources the board can cite:
* g a New Testament or full Bible found: eBible (nt/full flags), or a link whose scope is New
* Testament, Full Bible, or 27/66 parts (FCBH, Scripture Earth, PNG.Bible, Door43)
* y something found in their own language (gospel-plugin's access ladder), but no New Testament
* r nothing found in their own language in the sources checked
* A DIALECT is never coloured on its own evidence: every source catalogues by language, never by dialect.
* It carries its language's colour with `inh:1`, and the board draws it lighter and says so.
* A family or branch is coloured by the WORST language inside it (red if any has nothing), with counts,
* so the eye goes to the gap. The board recomputes these per list and per filter; the values here are the
* whole roster's.
*
* Chains of single-child branches are collapsed for drawing (their names kept in `via`), so a 12-deep path
* with no splits does not become 12 dots in a line. Codes Glottolog lacks go under one labelled node,
* "Not in Glottolog", never dropped.
*
* Inputs: data/glottolog/glottolog-subset.json, data/tables/gospel-internet.json, ebible-index.json,
* gospel-resource-links.json. Outputs: data/tables/gospel-internet-tree.json and
* gospel-internet-resources.json (+ their public/dx-maps/assets/tables copies). `--check` compares.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
const CHECK = process.argv.includes('--check')
const failures = []
const fail = (m) => failures.push(m)
const glotto = JSON.parse(readFileSync(resolve(REPO, 'data/glottolog/glottolog-subset.json'), 'utf8'))
const gi = T('gospel-internet.json')
const ebible = T('ebible-index.json').codes || {}
const links = T('gospel-resource-links.json').rows || []
// ── colour per language ────────────────────────────────────────────────────────────────────────────
const ntFound = new Set()
for (const [iso, v] of Object.entries(ebible)) if (v && (v.nt || v.full)) ntFound.add(iso)
const NT_SCOPE = /^(New Testament|Full Bible)$|^(27|66) parts/
for (const r of links) if (NT_SCOPE.test(r.scope || '')) ntFound.add(r.iso639_3)
const langAccess = new Map() // iso -> 'some' | 'none' (from the rows: gospel-plugin's ladder)
const groupsBy = new Map() // iso -> groups on the roster
for (const r of gi.rows) {
const some = (r.access_detail || 'none') !== 'none'
langAccess.set(r.iso639_3, langAccess.get(r.iso639_3) === 'some' || some ? 'some' : 'none')
groupsBy.set(r.iso639_3, (groupsBy.get(r.iso639_3) || 0) + 1)
}
const statusOf = (iso) => ntFound.has(iso) ? 'g' : langAccess.get(iso) === 'some' ? 'y' : 'r'
// ── the tree ────────────────────────────────────────────────────────────────────────────────────────
const L = new Map(glotto.languoids.map((l) => [l.id, { ...l, kids: [] }]))
const ROOT_MISSING = 'not-in-glottolog'
const roots = []
for (const n of L.values()) {
if (n.parent && L.has(n.parent)) L.get(n.parent).kids.push(n)
else if (!n.parent) roots.push(n)
else fail(`${n.id} (${n.name}) has a parent ${n.parent} outside the snapshot`)
}
// the codes Glottolog lacks: one labelled root, one leaf per code, so no group is left off the tree
if (glotto.not_in_glottolog.length) {
const miss = { id: ROOT_MISSING, name: 'Not in Glottolog', level: 'family', iso: '', parent: '', kids: [] }
const nameOf = new Map(gi.rows.map((r) => [r.iso639_3, r.language]))
for (const iso of glotto.not_in_glottolog) {
const leaf = { id: 'iso-' + iso, name: nameOf.get(iso) || iso, level: 'language', iso, parent: ROOT_MISSING, kids: [] }
L.set(leaf.id, leaf); miss.kids.push(leaf)
}
L.set(ROOT_MISSING, miss); roots.push(miss)
}
// isolates are language-level roots: keep them as one-node trees
const isoToNode = new Map()
for (const n of L.values()) if (n.iso) isoToNode.set(n.iso, n)
for (const iso of groupsBy.keys()) if (!isoToNode.has(iso)) fail(`language ${iso} of the roster is not on the tree`)
// collapse single-child branch chains (drawing only; names kept)
function collapse(n) {
n.kids = n.kids.map((k) => {
let c = k
const via = []
while (c.level === 'family' && c.kids.length === 1 && c.kids[0].level === 'family') { via.push(c.name); c = c.kids[0] }
if (via.length) c.via = via
return c
})
n.kids.sort((a, b) => a.name.localeCompare(b.name))
n.kids.forEach(collapse)
}
roots.forEach(collapse)
// ── radial layout per family ────────────────────────────────────────────────────────────────────────
const ARC = 1.0 // minimum arc between neighbouring leaves (layout units)
const STEP_MIN = 3.2 // minimum ring spacing
function leaves(n) { return n._leaves ??= (n.kids.length ? n.kids.reduce((s, k) => s + leaves(k), 0) : 1) }
function depth(n) { return n._depth ??= (n.kids.length ? 1 + Math.max(...n.kids.map(depth)) : 0) }
function layoutFamily(root) {
const nLeaves = leaves(root), d = depth(root)
const step = d ? Math.max(STEP_MIN, (nLeaves * ARC) / (2 * Math.PI * d)) : 0
let slot = 0
const place = (n, lvl) => {
if (!n.kids.length) { n._a = ((slot + 0.5) / nLeaves) * 2 * Math.PI; slot++ }
else { n.kids.forEach((k) => place(k, lvl + 1)); n._a = (n.kids[0]._a + n.kids[n.kids.length - 1]._a) / 2 }
n._r = lvl * step
}
place(root, 0)
// The enclosing circle is the tree's REAL extent (its outermost node) plus room for one label, not
// the deepest possible ring: sized by the ring, the packing wasted about a third of the board.
let far = 0
const walk = (n) => { far = Math.max(far, n._r); n.kids.forEach(walk) }
walk(root)
root._R = far + 1.6 + Math.min(3, Math.sqrt(nLeaves) * 0.25)
return root
}
roots.forEach(layoutFamily)
// ── pack the families: largest first, nearest free spot on a spiral ────────────────────────────────
const placed = []
for (const root of [...roots].sort((a, b) => b._R - a._R || a.name.localeCompare(b.name))) {
const R = root._R
let best = null
if (!placed.length) best = [0, 0]
for (let t = 0; !best; t += 0.05) {
const rad = 0.35 * t * Math.max(1, R * 0.2)
const x = rad * Math.cos(t), y = rad * Math.sin(t)
if (placed.every((p) => Math.hypot(p.x - x, p.y - y) >= p.R + R + 0.8)) best = [x, y]
}
placed.push({ x: best[0], y: best[1], R })
root._cx = best[0]; root._cy = best[1]
}
// ── to lng/lat through inverse Web Mercator, uniformly scaled, so the drawing is not distorted ─────
let ext = 1
for (const p of placed) ext = Math.max(ext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R)
const K = 0.34 / ext // the tapestry spans ~68% of the Mercator square
const toLngLat = (u, v) => {
const X = 0.5 + u * K, Y = 0.5 - v * K
const lng = (X - 0.5) * 360
const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * Y))) * 180 / Math.PI
return [Math.round(lng * 1e5) / 1e5, Math.round(lat * 1e5) / 1e5]
}
// ── emit nodes + edges ─────────────────────────────────────────────────────────────────────────────
const LV = { family: 'f', language: 'l', dialect: 'd' }
const nodes = []
function emit(n, root, parentId, isRoot) {
const x = root._cx + n._r * Math.cos(n._a), y = root._cy + n._r * Math.sin(n._a)
const lv = isRoot && n.level === 'family' ? 'f' : n.level === 'family' ? 'b' : LV[n.level] || 'b'
const node = { id: n.id, n: n.name, lv, p: parentId || '', c: toLngLat(x, y) }
if (n.iso) node.iso = n.iso
if (n.via) node.via = n.via
if (lv === 'l' || (lv === 'd' && n.iso && groupsBy.has(n.iso))) node.st = statusOf(n.iso)
if (n.iso && groupsBy.has(n.iso)) node.g = groupsBy.get(n.iso)
if (lv === 'f' || isRoot) node.R = Math.round(root._R * K * 1e6) / 1e6
nodes.push(node)
// Edges are NOT written: every node carries its parent id and both coordinates, so the board draws
// each parent->child line itself (4,300 lines in one GeoJSON source). It halves the file.
for (const k of n.kids) emit(k, root, n.id, false)
}
for (const root of roots) emit(root, root, '', true)
// dialects inherit their language's colour (never their own evidence)
const byId = new Map(nodes.map((n) => [n.id, n]))
for (const n of nodes) {
if (n.lv !== 'd' || n.st) continue
let p = byId.get(n.p)
while (p && !p.st) p = byId.get(p.p)
if (p && p.st) { n.st = p.st; n.inh = 1 }
}
// ── coverage and the resources table ───────────────────────────────────────────────────────────────
const onTree = new Set(nodes.filter((n) => n.iso).map((n) => n.iso))
const missingLangs = [...groupsBy.keys()].filter((i) => !onTree.has(i))
if (missingLangs.length) fail(`${missingLangs.length} roster languages missing from the tree: ${missingLangs.slice(0, 5).join(', ')}`)
const st = nodes.filter((n) => n.lv === 'l' || (n.lv === 'd' && n.iso && n.g)).reduce((m, n) => { m[n.st] = (m[n.st] || 0) + 1; return m }, {})
const levels = nodes.reduce((m, n) => { m[n.lv] = (m[n.lv] || 0) + 1; return m }, {})
const ours = new Set(groupsBy.keys())
const resources = links.filter((r) => ours.has(r.iso639_3)).map((r) => ({
iso: r.iso639_3, language: r.language, resource: r.resource, type: r.type, scope: r.scope || '', source: r.source, url: r.resource_url,
}))
const treeDoc = {
meta: {
title: 'Language tree — every family our people groups belong to, coloured by what the gospel sources hold',
generated_by: 'plugin-designs/gospel-internet/scripts/build-language-tree.mjs',
source: glotto.source, citation: glotto.citation, licence: glotto.licence, url: glotto.url, fetched: glotto.fetched,
newick_note: 'Glottolog publishes every subtree in Newick (text/newick), the standard text format for trees; this file is its drawing, with the classification path kept as parent links.',
colours: {
g: 'a New Testament or full Bible found in the language (eBible nt/full, or a resource scoped New Testament / Full Bible / 27 or 66 parts: FCBH, Scripture Earth, PNG.Bible, Door43)',
y: 'something found in the language (film, audio, stories, portions) but no New Testament',
r: 'nothing found in the language in the sources checked; never a claim that nothing exists',
parent: 'a family or branch shows the WORST colour inside it, with counts, so the gap is what the eye finds',
dialect: 'every source catalogues by LANGUAGE (ISO 639-3), never by dialect: a dialect carries its language\'s colour (inh:1) and is drawn lighter',
},
collapsed: 'chains of single-child branches are drawn as one link; the skipped names are in `via`',
coverage: { nodes: nodes.length, levels, language_status: st, not_in_glottolog: glotto.not_in_glottolog.length, families: roots.length },
},
nodes,
}
const resDoc = {
meta: { title: 'Gospel resources for the languages on the board', source: 'gospel-resource-links.json (gospel-plugin): every link already screened', rows: resources.length },
rows: resources,
}
if (failures.length) {
for (const f of failures.slice(0, 30)) console.error('FAIL ' + f)
console.error(`FAIL build-language-tree: ${failures.length}; nothing written`)
process.exitCode = 1
} else {
const outs = [
['gospel-internet-tree.json', JSON.stringify(treeDoc) + '\n'],
['gospel-internet-resources.json', JSON.stringify(resDoc) + '\n'],
]
let stale = false
for (const [f, body] of outs) for (const dir of ['data/tables', 'public/dx-maps/assets/tables']) {
const p = resolve(REPO, dir, f)
if (CHECK) { try { if (readFileSync(p, 'utf8') !== body) stale = true } catch { stale = true } }
else writeFileSync(p, body)
}
if (CHECK) { console.log(stale ? 'FAIL language tree is stale: re-run the builder' : 'ok language tree is current'); if (stale) process.exitCode = 1 }
else console.log('ok wrote gospel-internet-tree.json + gospel-internet-resources.json')
console.log(JSON.stringify(treeDoc.meta.coverage), 'resources', resources.length)
}

View file

@ -45,6 +45,11 @@ const get = (lc, key) => {
return typeof v === 'string' ? v : undefined
}
// SETTLED, not deferred: the correct word in that language IS the English spelling. Each is a decision,
// listed by locale and key so a new identical string is still caught.
const SAME_ON_PURPOSE = new Set(['fr:res.source', 'fr:res.type', 'ro:tree.dialect', 'fr:facet.religion',
'de:facet.region', 'de:facet.religion'])
let fails = 0
if (keys.size < 100) { console.log(`NOT MEASURED: read only ${keys.size} keys (expected 100+); the parser is broken, not the bundle`); process.exitCode = 75 }
else {
@ -57,7 +62,7 @@ else {
if (v === undefined) { fails++; console.log(`FAIL ${key}: missing from ${lc}`) }
// Only this bundle's own area is required to differ: shared keys (buttons.*, aria.*) are their
// owners' to judge, and a proper noun may legitimately match.
else if (key.startsWith('gospelInternet.') && v === en && /[a-z]{4,}/i.test(en) && !/^(Audio|Video|Film|Region|Religion)$/.test(en)) {
else if (key.startsWith('gospelInternet.') && v === en && /[a-z]{4,}/i.test(en) && !SAME_ON_PURPOSE.has(`${lc}:${key.slice(15)}`) && !/^(Audio|Video|Film)$/.test(en)) {
fails++; console.log(`FAIL ${key}: ${lc} is the English left in place ("${en}")`)
}
}

View file

@ -104,6 +104,36 @@ ok(summed === nCountries, `app chart counts each country once: ${summed} == ${nC
ok(Object.keys(doc.countries || {}).length === 175, `platform summary for all 175 countries (got ${Object.keys(doc.countries || {}).length})`)
bus.clearFilters()
// 10. the language tree: every roster language counted exactly once across the families, the colour counts
// equal a direct recount, and narrowing to a family keeps exactly the rows whose language is under it
const treeDoc = JSON.parse(readFileSync(resolve(REPO, 'data/tables/gospel-internet-tree.json'), 'utf8'))
const ix = M.indexTree(treeDoc)
const sc = M.treeScope(ix, rows)
const tot = { g: 0, y: 0, r: 0 }
for (const n of ix.nodes) if (!n.p) { const x = sc.get(n.id); tot.g += x.g; tot.y += x.y; tot.r += x.r }
const langsOnBoard = new Set(rows.map((r) => r.iso639_3))
ok(tot.g + tot.y + tot.r === langsOnBoard.size + (POISON ? 1 : 0), `tree counts every roster language once: ${tot.g + tot.y + tot.r} == ${langsOnBoard.size}`)
const direct = { g: 0, y: 0, r: 0 }
for (const iso of langsOnBoard) direct[ix.byId.get(ix.nodeOfIso.get(iso)).st]++
ok(direct.g === tot.g && direct.y === tot.y && direct.r === tot.r, `tree colour counts equal a direct recount (g ${tot.g} y ${tot.y} r ${tot.r})`)
const fam = ix.nodes.filter((n) => !n.p).sort((a, b) => ix.isos.get(b.id).size - ix.isos.get(a.id).size)[0]
const famIsos = [...ix.isos.get(fam.id)]
bus.setFilter('language', famIsos)
const famRows = M.filterRows(rows, bus).length
const expect = rows.filter((r) => ix.isos.get(fam.id).has(r.iso639_3)).length
ok(famRows === expect && famRows > 0, `narrowing to ${fam.n} keeps exactly its rows (${famRows} == ${expect})`)
// the worst-colour rule: a family with any red language in scope draws red
const redFam = ix.nodes.find((n) => !n.p && sc.get(n.id).r > 0)
ok(!redFam || sc.get(redFam.id).col === 'r', `a family with a red language draws red (${redFam ? redFam.n : 'none'})`)
// under a smaller scope the tree recounts: only the zero-access rows -> no green or yellow language left
bus.setFilter('language', null)
bus.setFilter('access_detail', 'none')
const scZero = M.treeScope(ix, M.filterRows(rows, bus))
let gy = 0
for (const n of ix.nodes) if (!n.p) { const x = scZero.get(n.id); gy += x.g + x.y }
ok(gy === 0, `with only zero-access groups in scope, no family counts a green or yellow language (${gy})`)
bus.clearFilters()
// 8. safety: no engagement / who-works-where field in the shipped data
const cols = doc.meta.columns || []
const banned = cols.filter((c) => /engage|worker|team|agenc|missionar/i.test(c))

View file

@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* check-gospel-internet-render.mjs — every PANEL of the board renders, with the real data, in en and ar.
*
* WHY: check:profiles-mount runs a profile's own setup and never renders its children (measured: a throw
* planted in GiMap's or GiTree's setup passed it). So no gate had ever executed this board's panels.
* This one server-renders them (Vue's renderer, through vite's module loader, no browser, no WebGL):
* every panel's setup, computeds and template run against the real table, tree and resources, and the
* HTML is asserted on CONTENT the panel must show, never on the panel's own count of itself.
* onMounted does not run under SSR, so map layers are not covered here; that is the browser's (rega's).
*
* `--poison` blanks the rows handed to the panels and requires the content assertions to FAIL.
* Exit 0 pass · 1 fail · 75 not measured (vite could not load the modules).
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const PLUGIN = resolve(HERE, '..')
const REPO = resolve(PLUGIN, '../..')
const POISON = process.argv.includes('--poison')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
let server
try {
const { createServer } = await import('vite')
server = await createServer({ root: REPO, configFile: resolve(REPO, 'vite.config.js'), logLevel: 'error',
server: { middlewareMode: true, hmr: false }, appType: 'custom', optimizeDeps: { noDiscovery: true, include: [] },
// node_modules is a symlink in a worktree, so vite would inline vue as source; keep the packages external
// so the components and this harness share ONE vue instance
ssr: { external: ['vue', 'vue/server-renderer', '@vue/server-renderer', 'vue-i18n', 'pinia'] } })
} catch (e) { console.log('NOT MEASURED: vite would not start: ' + e.message); process.exit(75) }
let fails = 0
const ok = (c, m) => { if (c) console.log('ok ' + m); else { fails++; console.log('FAIL ' + m) } }
try {
const load = (p) => server.ssrLoadModule(resolve(PLUGIN, p))
const { createSSRApp, h, ref, markRaw } = await import('vue')
const { renderToString } = await import('vue/server-renderer')
const i18n = await server.ssrLoadModule(resolve(REPO, 'library/i18n/index.js'))
const M = await load('src/model.js')
const { useBoard } = await load('src/useBoard.js')
const panels = {}
for (const n of ['GiFacetRail', 'GiMap', 'GiCharts', 'GiGrid', 'GiDrawer', 'GiTree', 'GiResources']) panels[n] = (await load(`src/components/${n}.vue`)).default
const gi = T('gospel-internet.json')
const rows = POISON ? [] : M.normalizeRows(gi)
const treeDoc = T('gospel-internet-tree.json')
const res = T('gospel-internet-resources.json')
const open = rows.find((r) => r.access_detail === 'none' && r.province) || rows[0]
async function render(lang) {
const app = createSSRApp({
setup() {
const b = useBoard()
b.all.value = markRaw(rows)
b.bySlug.value = markRaw(new Map(rows.map((r) => [r.slug, r])))
b.meta.value = markRaw(gi.meta)
b.countries.value = markRaw(gi.countries || {})
b.tree.value = markRaw({ ...M.indexTree(treeDoc), meta: treeDoc.meta })
b.resources.value = markRaw(res)
b.loading.value = false
if (open) b.openSlug.value = open.slug
// narrow to the largest family through the tree, as a click would
const fam = b.tree.value.nodes.filter((n) => !n.p).sort((x, y) => b.tree.value.isos.get(y.id).size - b.tree.value.isos.get(x.id).size)[0]
b.setTreeNode(fam.id)
return () => h('div', Object.values(panels).map((P) => h(P)))
},
})
// the channel the real board uses: the bundle installs createAppI18n, and ProfileLoader sets its
// locale from profile-config (never a window global, which does not exist here)
// createAppI18n reads <html lang> to detect a locale: give it the one document field it reads
globalThis.document = { documentElement: { lang } }
const appI18n = i18n.createAppI18n()
app.use(appI18n)
appI18n.global.locale.value = lang
app.provide('profileConfig', ref({ profile: 'board', lang }))
const errors = []
app.config.errorHandler = (e) => { errors.push(String(e && e.stack || e)) }
app.config.warnHandler = (m) => { if (/Unhandled error|Property .* was accessed during render but is not defined/.test(m)) errors.push(m) }
const html = await renderToString(app)
return { html, errors }
}
const en = await render('en')
ok(en.errors.length === 0, `en: every panel rendered with no error (${en.errors.length}${en.errors[0] ? ': ' + en.errors[0].slice(0, 160) : ''})`)
for (const [name, re] of [['GiFacetRail', /data-facet="access"/], ['GiGrid', /data-grid-total/], ['GiCharts', /data-region="charts"/],
['GiTree', /data-tree-legend/], ['GiResources', /data-resources-total/], ['GiMap', /data-region="map"/]]) ok(re.test(en.html), `en: ${name} rendered its region`)
ok(!!open && en.html.includes(open.people_group), `en: the drawer shows the opened group (${open ? open.people_group : 'no row'})`)
ok(/data-notice="approximate-pin"/.test(en.html), 'en: the drawer carries the approximate-pin notice')
const fam = treeDoc.nodes.filter((n) => !n.p).sort((a, b) => b.n.length - a.n.length)
ok(/data-tree-card/.test(en.html), 'en: the tree card shows the node the board is narrowed to')
ok(/href="https?:\/\/[^"]+"/.test(en.html.slice(en.html.indexOf('data-region="resources"'))), 'en: the resources tab lists real links')
ok(/\b\d[\d,]* of [\d,]+ people groups\b/.test(en.html), 'en: the grid prints its scope count')
void fam
const ar = await render('ar')
ok(ar.errors.length === 0, `ar: every panel rendered with no error (${ar.errors.length})`)
const arWords = ['شجرة', 'بلغتهم', 'عوامل التصفية']
const found = arWords.filter((w) => ar.html.includes(w))
ok(found.length === arWords.length, `ar: the catalogue reaches the panels (${found.length}/${arWords.length} Arabic strings found)`)
ok(!ar.html.includes('Language tree: every language family'), 'ar: the English tree label is not left in place')
} catch (e) {
console.log('NOT MEASURED: ' + (e && e.stack || e).toString().split('\n').slice(0, 3).join(' | '))
await server.close()
process.exit(75)
}
await server.close()
if (POISON) { console.log(fails ? `POISON OK: ${fails} caught` : 'POISON FAILED'); process.exitCode = fails ? 0 : 1 }
else { console.log(fails ? `FAIL check-gospel-internet-render: ${fails}` : 'PASS check-gospel-internet-render'); process.exitCode = fails ? 1 : 0 }

View file

@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* check-language-tree.mjs — the language tree is complete, coloured by its stated rule, and laid out as a
* tapestry that does not overlap. Headless: the geometry is measured in Web Mercator pixels at zoom 0
* (the space the map engine draws it in), no browser.
*
* 1. current: the committed files are what the builder makes from main's inputs;
* 2. complete: every roster language (ISO 639-3) is a node, and none is lost;
* 3. colours: a language is g/y/r by the stated rule (recomputed here from the same inputs);
* no dialect claims its own evidence (every dialect status is inherited, inh:1, unless its
* ISO code is itself a roster language);
* 4. tapestry: no two FAMILY circles overlap; every node lies inside its own family's circle;
* 5. spacing: no two nodes of one family closer than a floor (they would print one on the other).
* `--png <file>` also writes a preview (dots + links, coloured) to read with an image viewer.
* `--poison` shifts one family onto another and requires check 4 to FAIL.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
import { deflateSync } from 'node:zlib'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const argv = process.argv.slice(2)
const POISON = argv.includes('--poison')
const PNG = argv.includes('--png') ? argv[argv.indexOf('--png') + 1] : ''
let fails = 0
const ok = (c, m) => { if (c) console.log('ok ' + m); else { fails++; console.log('FAIL ' + m) } }
let current = true
try { execFileSync('node', [resolve(HERE, 'build-language-tree.mjs'), '--check'], { stdio: 'pipe' }) } catch { current = false }
ok(current, 'the tree files are what the builder makes from main\'s inputs')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
const tree = T('gospel-internet-tree.json')
const gi = T('gospel-internet.json')
const nodes = tree.nodes
const byId = new Map(nodes.map((n) => [n.id, n]))
// 2. complete
const langs = new Set(gi.rows.map((r) => r.iso639_3))
const onTree = new Set(nodes.filter((n) => n.iso).map((n) => n.iso))
const lost = [...langs].filter((l) => !onTree.has(l))
ok(lost.length === 0, `every roster language is on the tree (${langs.size}; lost: ${lost.slice(0, 5).join(', ') || 'none'})`)
ok(nodes.every((n) => !n.p || byId.has(n.p)), 'every parent link resolves')
// 3. colours, recomputed
const eb = T('ebible-index.json').codes || {}
const links = T('gospel-resource-links.json').rows
const nt = new Set(Object.entries(eb).filter(([, v]) => v && (v.nt || v.full)).map(([k]) => k))
for (const r of links) if (/^(New Testament|Full Bible)$|^(27|66) parts/.test(r.scope || '')) nt.add(r.iso639_3)
const some = new Set(gi.rows.filter((r) => r.access_detail && r.access_detail !== 'none').map((r) => r.iso639_3))
const want = (iso) => nt.has(iso) ? 'g' : some.has(iso) ? 'y' : 'r'
const wrong = nodes.filter((n) => n.iso && langs.has(n.iso) && n.st !== want(n.iso))
ok(wrong.length === 0, `every roster language coloured by the rule (${wrong.length} wrong${wrong[0] ? ': ' + wrong[0].iso : ''})`)
const ownDialect = nodes.filter((n) => n.lv === 'd' && !n.inh && !(n.iso && langs.has(n.iso)))
ok(ownDialect.length === 0, `no dialect claims its own evidence (${ownDialect.length})`)
// 4 + 5. geometry in Mercator pixels at z0 (512-px world, the engines' tile size)
const W = 512
const px = ([lng, lat]) => {
const x = (lng + 180) / 360 * W
const s = Math.sin(lat * Math.PI / 180)
const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * W
return [x, y]
}
const rootOf = (n) => { let c = n; while (c.p) c = byId.get(c.p); return c }
const roots = nodes.filter((n) => !n.p)
const circ = new Map(roots.map((r) => [r.id, { c: px(r.c), R: (r.R || 0) * W }]))
if (POISON && roots.length > 1) { const [a, b] = roots; circ.get(a.id).c = circ.get(b.id).c }
let overlaps = 0
for (let i = 0; i < roots.length; i++) for (let j = i + 1; j < roots.length; j++) {
const A = circ.get(roots[i].id), B = circ.get(roots[j].id)
if (Math.hypot(A.c[0] - B.c[0], A.c[1] - B.c[1]) < A.R + B.R - 1e-6) overlaps++
}
ok(overlaps === 0, `no two family circles overlap (${overlaps} overlapping pairs of ${roots.length} families)`)
let outside = 0
for (const n of nodes) {
const r = rootOf(n), C = circ.get(r.id), p = px(n.c)
if (Math.hypot(p[0] - C.c[0], p[1] - C.c[1]) > C.R + 1e-6) outside++
}
ok(outside === 0, `every node lies inside its family's circle (${outside} outside)`)
// spacing: the true nearest pair, via a 1-px grid (any pair closer than 1 px shares a 3x3 block)
const cell = 1
const grid = new Map()
let minD = Infinity, pair = null
nodes.forEach((n, i) => {
const p = px(n.c), cx = Math.floor(p[0] / cell), cy = Math.floor(p[1] / cell)
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
for (const [q, j] of grid.get(`${cx + dx},${cy + dy}`) || []) { const d = Math.hypot(p[0] - q[0], p[1] - q[1]); if (d < minD) { minD = d; pair = [i, j] } }
}
const k = `${cx},${cy}`
if (!grid.has(k)) grid.set(k, [])
grid.get(k).push([p, i])
})
const ext = roots.reduce((m, r) => Math.max(m, circ.get(r.id).R), 0)
if (minD === Infinity) console.log('NOT MEASURED: no two nodes within 1 px at z0; the spacing measure found nothing to compare')
console.log(`info closest two nodes: ${minD.toFixed(4)} px at z0 = ${(minD * 64).toFixed(1)} px at z6 (${pair ? nodes[pair[0]].n + ' / ' + nodes[pair[1]].n : ''}); largest family radius ${ext.toFixed(1)} px at z0`)
// Dialect dots first show at z6; two dots closer than 4 px there print as one.
ok(minD * 2 ** 6 >= 4, 'no two nodes print on one another at zoom 6 (>= 4 px apart)')
if (PNG) {
// a tiny RGBA rasteriser: links, then dots, then family rings. No dependency.
const S = 1400, scale = S / W
const buf = Buffer.alloc(S * S * 4, 255)
for (let i = 0; i < S * S; i++) { buf[i * 4] = 243; buf[i * 4 + 1] = 243; buf[i * 4 + 2] = 241 }
const set = (x, y, [r, g, b], a = 1) => {
x = Math.round(x); y = Math.round(y)
if (x < 0 || y < 0 || x >= S || y >= S) return
const i = (y * S + x) * 4
buf[i] = buf[i] * (1 - a) + r * a; buf[i + 1] = buf[i + 1] * (1 - a) + g * a; buf[i + 2] = buf[i + 2] * (1 - a) + b * a
}
const line = (a, b, col, alpha) => { const n = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])) + 1; for (let i = 0; i <= n; i++) set(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n, col, alpha) }
const disc = (c, r, col) => { for (let dx = -r; dx <= r; dx++) for (let dy = -r; dy <= r; dy++) if (dx * dx + dy * dy <= r * r) set(c[0] + dx, c[1] + dy, col) }
const COL = { g: [16, 150, 90], y: [232, 180, 40], r: [180, 35, 24] }
// centre the tapestry in the frame
const all = nodes.map((n) => px(n.c))
const mx = (Math.min(...all.map((p) => p[0])) + Math.max(...all.map((p) => p[0]))) / 2
const my = (Math.min(...all.map((p) => p[1])) + Math.max(...all.map((p) => p[1]))) / 2
const span = Math.max(...all.map((p) => Math.max(Math.abs(p[0] - mx), Math.abs(p[1] - my)))) * 2.1
const tp = (p) => [(p[0] - mx) / span * S + S / 2, (p[1] - my) / span * S + S / 2]
for (const n of nodes) if (n.p) line(tp(px(byId.get(n.p).c)), tp(px(n.c)), [110, 118, 110], 0.35)
for (const r of roots) { const C = circ.get(r.id); const c = tp(C.c), R = C.R / span * S; for (let a = 0; a < 6.283; a += 0.5 / Math.max(R, 1)) set(c[0] + R * Math.cos(a), c[1] + R * Math.sin(a), [150, 160, 150], 0.6) }
const worst = new Map()
for (const n of nodes) if (n.st && !n.inh) { let p = n; while (p) { const w = worst.get(p.id); if (!w || 'gyr'.indexOf(n.st) > 'gyr'.indexOf(w)) worst.set(p.id, n.st); p = p.p ? byId.get(p.p) : null } }
for (const n of nodes) {
const st = n.st || worst.get(n.id) || 'g'
const r = n.lv === 'f' ? 5 : n.lv === 'b' ? 1 : n.lv === 'l' ? 2 : 1
disc(tp(px(n.c)), r, n.inh ? COL[st].map((v) => Math.round(v + (255 - v) * 0.5)) : COL[st])
}
const crcTable = new Int32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c })
const crc = (b) => { let c = -1; for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0 }
const chunk = (t, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]) }
const raw = Buffer.alloc(S * (S * 4 + 1))
for (let y = 0; y < S; y++) { raw[y * (S * 4 + 1)] = 0; buf.copy(raw, y * (S * 4 + 1) + 1, y * S * 4, (y + 1) * S * 4) }
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(S, 0); ihdr.writeUInt32BE(S, 4); ihdr[8] = 8; ihdr[9] = 6
writeFileSync(PNG, Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]))
console.log('info preview written: ' + PNG)
}
if (POISON) { console.log(fails ? `POISON OK: ${fails} caught` : 'POISON FAILED'); process.exitCode = fails ? 0 : 1 }
else { console.log(fails ? `FAIL check-language-tree: ${fails}` : 'PASS check-language-tree'); process.exitCode = fails ? 1 : 0 }

View file

@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* fetch-glottolog.mjs — the language TREE's source: a snapshot of Glottolog (CLDF) cut to the languoids
* our people groups need.
*
* WHY GLOTTOLOG. It is the open, citable catalogue that classifies every languoid as a family, a
* language or a dialect, with its full path from the family root (Max Planck Institute for Evolutionary
* Anthropology, CC BY 4.0). IMB's LanguageFamily and library/data/langFamilyByIso.json stop at ONE level
* ("Indo-European"), which cannot draw a tree. Glottolog also publishes each subtree in NEWICK, the
* standard text format for trees, so this board invents no graph language of its own.
*
* WHAT IS KEPT: every language whose ISO 639-3 code a DOXA group uses, every ancestor on its path, and
* every dialect under it. Codes Glottolog does not carry (IMB private-use 'q' codes) are LISTED in the
* snapshot, never dropped silently.
*
* Network: raw.githubusercontent.com/glottolog/glottolog-cldf (master). Writes
* data/glottolog/glottolog-subset.json. Refuses to overwrite a snapshot with a much smaller one.
* Usage: node plugin-designs/gospel-internet/scripts/fetch-glottolog.mjs
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const OUT = resolve(REPO, 'data/glottolog/glottolog-subset.json')
const BASE = 'https://raw.githubusercontent.com/glottolog/glottolog-cldf/master/cldf/'
/** RFC-4180 CSV: quoted fields may hold commas, quotes ("") and newlines. */
function parseCsv(text) {
const rows = []
let row = [], field = '', q = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (q) {
if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++ } else q = false }
else field += c
} else if (c === '"') q = true
else if (c === ',') { row.push(field); field = '' }
else if (c === '\n') { row.push(field.replace(/\r$/, '')); rows.push(row); row = []; field = '' }
else field += c
}
if (field || row.length) { row.push(field); rows.push(row) }
const [head, ...body] = rows
return body.filter((r) => r.length > 1).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ''])))
}
async function get(name) {
const res = await fetch(BASE + name)
if (!res.ok) throw new Error(`${name} HTTP ${res.status}`)
return res.text()
}
const meta = JSON.parse(await get('cldf-metadata.json'))
const languages = parseCsv(await get('languages.csv'))
const values = parseCsv(await get('values.csv'))
const classification = new Map()
for (const v of values) if (v.Parameter_ID === 'classification') classification.set(v.Language_ID, v.Value)
const byId = new Map(languages.map((l) => [l.ID, l]))
const byIso = new Map(languages.filter((l) => l.ISO639P3code).map((l) => [l.ISO639P3code, l]))
const access = JSON.parse(readFileSync(resolve(REPO, 'data/tables/doxa-gospel-access.json'), 'utf8')).rows
const wanted = [...new Set(access.map((r) => r.iso639_3).filter(Boolean))].sort()
const keep = new Set()
const missing = []
for (const iso of wanted) {
const l = byIso.get(iso)
if (!l) { missing.push(iso); continue }
keep.add(l.ID)
for (const g of (classification.get(l.ID) || '').split('/').filter(Boolean)) keep.add(g)
}
// every dialect under a kept language (a dialect's path runs through its language)
for (const l of languages) {
if (l.Level !== 'dialect') continue
const path = (classification.get(l.ID) || '').split('/')
if (l.Language_ID && keep.has(l.Language_ID)) { keep.add(l.ID); for (const g of path) if (g) keep.add(g) }
}
const languoids = [...keep].sort().map((id) => {
const l = byId.get(id)
const path = (classification.get(id) || '').split('/').filter(Boolean)
return { id, name: l ? l.Name : id, level: l ? l.Level : 'family', iso: l?.ISO639P3code || '', parent: path.length ? path[path.length - 1] : '',
family: l?.Family_ID || '', language: l?.Language_ID || '' }
})
const doc = {
source: meta['dc:title'] || 'Glottolog (CLDF)',
citation: meta['dc:bibliographicCitation'] || '',
licence: meta['dc:license'] || 'https://creativecommons.org/licenses/by/4.0/',
url: 'https://glottolog.org',
fetched: new Date().toISOString().slice(0, 10),
scope: 'languoids needed by the ISO 639-3 codes of DOXA\'s people groups: each language, every ancestor on its classification path, every dialect under it',
wanted: wanted.length,
not_in_glottolog: missing,
languoids,
}
if (existsSync(OUT)) {
const prev = JSON.parse(readFileSync(OUT, 'utf8'))
if (languoids.length < 0.9 * (prev.languoids || []).length) {
console.error(`REFUSING: ${languoids.length} languoids would replace ${prev.languoids.length} (a >10% drop looks like a bad fetch)`)
process.exit(1)
}
}
mkdirSync(dirname(OUT), { recursive: true })
writeFileSync(OUT, JSON.stringify(doc) + '\n')
const lv = languoids.reduce((m, l) => { m[l.level] = (m[l.level] || 0) + 1; return m }, {})
console.log(`ok ${OUT.replace(REPO + '/', '')}: ${languoids.length} languoids`, lv, `${wanted.length - missing.length}/${wanted.length} codes found; not in Glottolog: ${missing.length}`)

View file

@ -0,0 +1,65 @@
/**
* boardEngine.js — build one map on whichever engine the embed asked for, the way this board builds all
* of its maps: engine from resolveMapProvider, script + CSS ensured, RTL shaping installed before `new
* Map`, the style from the registry ('paper' = Mapbox light / OpenFreeMap positron), labels in the
* reader's language, attribution added. Used by the geographic map AND the language-tree whiteboard, so
* the two can never drift apart on an engine detail.
*
* whiteboard:true hides every basemap layer except the background once the style loads. The style is
* still loaded because it is what declares the GLYPH server: a hand-typed glyph URL is how labels
* silently vanished on one engine before (library/maps/label-font.js).
*/
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl, engineUiLocale } from '@map/composables/useMapInstance.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
export function readerLang(profileConfig) {
const pc = profileConfig || {}
if (pc.locale || pc.lang) return String(pc.locale || pc.lang)
try { return new URLSearchParams(location.search).get('lang') || document.documentElement.lang || 'en' } catch { return 'en' }
}
/**
* @returns {Promise<{ map, provider } | { error: string, provider }>} resolves once the style has loaded
*/
export async function createBoardMap(el, profileConfig, { center = [60, 20], zoom = 1.4, whiteboard = false, boundaries = true, background = '#f3f3f1' } = {}) {
const provider = resolveMapProvider(profileConfig)
try { await ensureEngineScript(provider) } catch (_) { /* reported below */ }
const gl = engineGlobal(provider)
if (!gl) return { provider, error: provider === 'maplibre' ? 'MapLibre' : 'Mapbox' }
installRtlTextPlugin(gl)
const css = engineStylesheetUrl(provider)
if (css) await ensureEngineCSS(el, css)
const lang = readerLang(profileConfig)
const tk = profileConfig?.tk || (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
const map = new gl.Map({
container: el,
style: resolveNamedStyle('paper', provider).style || 'mapbox://styles/mapbox/light-v11',
accessToken: tk,
locale: engineUiLocale(lang, provider),
center, zoom, projection: 'mercator', attributionControl: false,
// a whiteboard is not a world: no wrap-around copies of the tapestry at low zoom
renderWorldCopies: !whiteboard,
})
addMapAttribution(map, { boundaries, basemap: !whiteboard })
await new Promise((res) => map.once('load', res))
if (whiteboard) {
for (const l of map.getStyle().layers || []) {
try {
if (l.type === 'background') map.setPaintProperty(l.id, 'background-color', background)
else map.setLayoutProperty(l.id, 'visibility', 'none')
} catch (_) { /* a layer the engine will not touch is left as it is */ }
}
} else {
try { localizeBasemapLabels(map, lang) } catch (_) { /* a nicety, never a broken map */ }
}
return { map, provider }
}
/** Reduced motion, read LIVE (never cached): under it every camera change is a jump. */
export function reducedMotion() {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } catch (_) { return false }
}

View file

@ -21,12 +21,6 @@
* how many share a point); the band is the COUNTRY's figure; a state's NFHS-5 figure is the state's.
*/
import { ref, shallowRef, computed, watch, onMounted, onBeforeUnmount, markRaw } from 'vue'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl, engineUiLocale } from '@map/composables/useMapInstance.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
@ -35,12 +29,12 @@ import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { useBoardState, EMBED_PATH } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { createBoardMap, reducedMotion } from '../boardEngine.js'
import { ACCESS_DETAILS, BANDS, DETAIL_FILL, DETAIL_RIM, BAND_COLOR, label } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const provider = resolveMapProvider(board.profileConfig)
const mapEl = ref(null)
const mapRef = shallowRef(null)
@ -54,12 +48,6 @@ let unmounted = false
let adm1Table = null // { [shapeName]: { women, men } } India, NFHS-5
const nfhsAttribution = ref('')
const readerLang = () => {
const pc = board.profileConfig.value || {}
if (pc.locale || pc.lang) return String(pc.locale || pc.lang)
try { return new URLSearchParams(location.search).get('lang') || document.documentElement.lang || 'en' } catch { return 'en' }
}
// ── pins ────────────────────────────────────────────────────────────────────────────────────────────
function pinFC() {
const inScope = new Set(board.filtered.value.map((r) => r.slug))
@ -224,7 +212,6 @@ watch(() => board.filtered.value, paintScope)
// ── selection -> ring + camera (glue to kit useSelectionCamera: debounce 150, one move, jump if reduced) ──
const CAMERA = { DEBOUNCE: 150, DURATION: 1200, PADDING: 70, FIT_MAX_ZOOM: 5.5, SINGLE_ZOOM: 5.2, WIDE_SPAN: 110 }
const reducedMotion = () => { try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } catch (_) { return false } }
let camTimer = null
function moveCamera(target) {
const map = mapRef.value
@ -274,15 +261,6 @@ const assetsBase = () => resolveAssetsBase(board.profileConfig.value)
const getJson = (path) => fetch(`${assetsBase()}/${path}`).then((r) => { if (!r.ok) throw new Error(`${path} HTTP ${r.status}`); return r.json() })
onMounted(async () => {
try { await ensureEngineScript(provider) } catch (_) { /* reported just below */ }
const gl = engineGlobal(provider)
if (unmounted) return
if (!gl) {
const engine = provider === 'maplibre' ? 'MapLibre' : 'Mapbox'
err.value = T('messages.engineMissing', `The ${engine} map engine did not load.`, { engine })
return
}
installRtlTextPlugin(gl)
const adm0P = getJson('boundaries/world-adm0.geojson')
const indP = getJson('boundaries/gb-IND-ADM1-full.geojson').catch(() => null)
const chnP = getJson('boundaries/gb-CHN-ADM1.geojson').catch(() => null)
@ -296,51 +274,51 @@ onMounted(async () => {
nfhsAttribution.value = (t.meta && t.meta.sources && t.meta.sources.nfhs5 && t.meta.sources.nfhs5.attribution) || ''
}).catch(() => { adm1Table = null })
try {
const css = engineStylesheetUrl(provider)
if (css && mapEl.value) await ensureEngineCSS(mapEl.value, css)
if (unmounted || !mapEl.value) return
const tk = board.profileConfig.value?.tk || (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
const map = new gl.Map({
container: mapEl.value,
style: resolveNamedStyle('paper', provider).style || 'mapbox://styles/mapbox/light-v11',
accessToken: tk,
locale: engineUiLocale(readerLang(), provider),
center: [60, 20], zoom: 1.4, projection: 'mercator', attributionControl: false,
})
if (!mapEl.value) return
const made = await createBoardMap(mapEl.value, board.profileConfig.value)
if (made.error) { err.value = T('messages.engineMissing', `The ${made.error} map engine did not load.`, { engine: made.error }); return }
const map = made.map
if (unmounted) { try { map.remove() } catch (_) {} return }
mapRef.value = markRaw(map)
addMapAttribution(map, { boundaries: true })
map.once('load', async () => {
try { localizeBasemapLabels(map, readerLang()) } catch (_) { /* a nicety, never a broken map */ }
const [adm0, ind, chn] = await Promise.all([adm0P, indP, chnP, nfhsP])
if (unmounted) return
// wait for the rows too: the band per country comes from the joined table
if (board.loading.value) await new Promise((res) => { const stop = watch(() => board.loading.value, (l) => { if (!l) { stop(); res() } }) })
addCountries(map, adm0)
addPins(map)
if (ind || chn) {
addProvinces(map, ind, chn)
provinceKeys = [...(ind ? ind.features.map((f) => `IND|${f.properties.shapeName}`) : []),
...(chn ? chn.features.map((f) => `CHN|${f.properties.shapeName}`) : [])]
}
ready.value = true
paintScope()
if (bus.selectionSet.size) moveCamera(boundsOf([...bus.selectionSet].map((s) => board.bySlug.value.get(s)).filter(Boolean)))
else if (board.view.value === 'province') moveCamera({ bounds: [[68, 8], [123, 45]] })
// test hook: what a probe needs to assert DATA drew, not just the basemap (PAINT GATES lesson)
try {
window.__giMap = map
window.__giState = () => ({
provider, pins: map.getSource('gi-pins') ? pinFC().features.length : 0,
renderedPins: map.queryRenderedFeatures({ layers: ['gi-pins'] }).length,
renderedCountries: map.queryRenderedFeatures({ layers: ['gi-adm0-fill'] }).length,
provinces: provinceKeys.length, inScope: board.filtered.value.length,
selected: [...bus.selectionSet], hover: bus.hoverKey.value,
})
} catch (_) {}
})
const [adm0, ind, chn] = await Promise.all([adm0P, indP, chnP, nfhsP])
if (unmounted) return
// wait for the rows too: the band per country comes from the joined table
if (board.loading.value) await new Promise((res) => { const stop = watch(() => board.loading.value, (l) => { if (!l) { stop(); res() } }) })
addCountries(map, adm0)
addPins(map)
if (ind || chn) {
addProvinces(map, ind, chn)
provinceKeys = [...(ind ? ind.features.map((f) => `IND|${f.properties.shapeName}`) : []),
...(chn ? chn.features.map((f) => `CHN|${f.properties.shapeName}`) : [])]
}
ready.value = true
paintScope()
if (bus.selectionSet.size) moveCamera(boundsOf([...bus.selectionSet].map((s) => board.bySlug.value.get(s)).filter(Boolean)))
else if (board.view.value === 'province') moveCamera({ bounds: [[68, 8], [123, 45]] })
// test hook: what a probe needs to assert DATA drew, not just the basemap (PAINT GATES lesson)
try {
window.__giMap = map
window.__giState = () => ({
provider: made.provider, pins: map.getSource('gi-pins') ? pinFC().features.length : 0,
renderedPins: map.queryRenderedFeatures({ layers: ['gi-pins'] }).length,
renderedCountries: map.queryRenderedFeatures({ layers: ['gi-adm0-fill'] }).length,
provinces: provinceKeys.length, inScope: board.filtered.value.length,
selected: [...bus.selectionSet], hover: bus.hoverKey.value,
})
} catch (_) {}
} catch (e) { err.value = String(e && e.message || e) }
})
// The engines only follow WINDOW resizes: a dragged rail, a toggled panel or an un-hidden map must
// resize the canvas too, or it stretches the old bitmap.
let ro = null
onMounted(() => {
if (mapEl.value && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => { try { mapRef.value && mapRef.value.resize() } catch (_) {} })
ro.observe(mapEl.value)
}
})
onBeforeUnmount(() => {
try { ro && ro.disconnect() } catch (_) {}
unmounted = true
if (camTimer) clearTimeout(camTimer)
try { mapRef.value && mapRef.value.remove() } catch (_) {}

View file

@ -0,0 +1,144 @@
<script setup>
/**
* GiResources.vue — the second tab under the board: every gospel resource for the LANGUAGES of the people
* groups in scope. Pick a family on the tree, a country on the map or a facet in the rail, and this lists
* what already exists for those languages: type, scope, source, and the link.
*
* The rows are gospel-plugin's screened links (gospel-resource-links.json, cut to the board's languages at
* build). Scope is the board's: a language is listed when at least one group in scope speaks it, and the
* "groups here" column counts those groups. CSV exports exactly what is listed.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { buildCsv, safeHref } from '../model.js'
const T = useGiT()
const board = useBoardState()
const fmt = (n) => (Number(n) || 0).toLocaleString()
onMounted(() => { board.loadTree().catch(() => {}) })
const groupsByIso = computed(() => {
const m = new Map()
for (const r of board.filtered.value) m.set(r.iso639_3, (m.get(r.iso639_3) || 0) + 1)
return m
})
const sort = ref({ key: 'groups', dir: -1 })
const rows = computed(() => {
const res = board.resources.value
if (!res) return []
const g = groupsByIso.value
const out = res.rows.filter((r) => g.has(r.iso)).map((r) => ({ ...r, groups: g.get(r.iso) }))
const { key, dir } = sort.value
return out.sort((a, b) => key === 'groups' ? (a.groups - b.groups) * dir || a.language.localeCompare(b.language)
: String(a[key] ?? '').localeCompare(String(b[key] ?? '')) * dir)
})
const langsListed = computed(() => new Set(rows.value.map((r) => r.iso)).size)
const langsInScope = computed(() => groupsByIso.value.size)
const COLS = computed(() => [
{ key: 'language', label: T('gospelInternet.col.language', 'Language'), w: 190 },
{ key: 'type', label: T('gospelInternet.res.type', 'Type'), w: 150 },
{ key: 'resource', label: T('gospelInternet.res.resource', 'Resource'), w: 260 },
{ key: 'scope', label: T('gospelInternet.res.scope', 'Scope'), w: 190 },
{ key: 'source', label: T('gospelInternet.res.source', 'Source'), w: 170 },
{ key: 'groups', label: T('gospelInternet.res.groups', 'Groups here'), w: 100, num: true },
])
function sortBy(c) { sort.value = sort.value.key === c.key ? { key: c.key, dir: -sort.value.dir } : { key: c.key, dir: c.num ? -1 : 1 } }
// windowing, as the groups grid does
const ROW_H = 32
const scroller = ref(null)
const scrollTop = ref(0)
const viewH = ref(400)
const first = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - 6))
const slice = computed(() => rows.value.slice(first.value, Math.min(rows.value.length, Math.ceil((scrollTop.value + viewH.value) / ROW_H) + 6)))
let ro = null
onMounted(() => {
if (scroller.value && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => { viewH.value = scroller.value ? scroller.value.clientHeight : 400 })
ro.observe(scroller.value)
}
})
onBeforeUnmount(() => { try { ro && ro.disconnect() } catch (_) {} })
watch(() => board.filtered.value, () => { if (scroller.value) scroller.value.scrollTop = 0; scrollTop.value = 0 })
const status = ref('')
function exportCsv() {
const cols = [{ key: 'iso', header: 'iso639_3' }, { key: 'language', header: 'language' }, { key: 'type', header: 'type' },
{ key: 'resource', header: 'resource' }, { key: 'scope', header: 'scope' }, { key: 'source', header: 'source' },
{ key: 'url', header: 'url' }, { key: 'groups', header: 'groups_in_scope' }]
const filters = Object.entries(board.bus.filterSet).map(([k, v]) => `${k}=${Array.isArray(v) ? v.join('|') : v}`).join('; ') || 'none'
const csv = buildCsv(rows.value, cols, { provenance: `# Gospel x Internet resources export — list: ${board.listId.value}; filters: ${filters}${board.treeNode.value ? '; tree node: ' + board.treeNode.value : ''}; rows: ${rows.value.length}; source: gospel-resource-links (gospel-plugin, every link screened)` })
try { navigator.clipboard && navigator.clipboard.writeText(csv).catch(() => {}) } catch (_) {}
try {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
const a = document.createElement('a'); a.href = url; a.download = 'gospel-internet-resources.csv'; a.style.display = 'none'
document.body.appendChild(a); a.click(); setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
} catch (_) {}
try { window.__giLastResourcesCsv = csv } catch (_) {}
status.value = T('gospelInternet.grid.exported', '{n} rows exported (copied and downloaded).', { n: fmt(rows.value.length) })
setTimeout(() => { status.value = '' }, 4000)
}
useShadowStyles(
'.gir{display:flex;flex-direction:column;height:100%;min-height:0;background:#fff;color:#3b463d;font:13px/1.3 Roboto,-apple-system,"Segoe UI",sans-serif}' +
'.gir-bar{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:6px 10px;border-bottom:1px solid #d1d4cd;background:#f3f3f1}' +
'.gir-count{font-weight:600;font-variant-numeric:tabular-nums}' +
'.gir-note{font-size:11.5px;color:#6d766e}' +
'.gir-btn{border:1px solid #3b463d;background:#3b463d;color:#fff;border-radius:6px;padding:4px 10px;min-height:28px;font:inherit;font-size:12.5px;cursor:pointer}' +
'.gir-btn:focus-visible,.gir-th button:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
'.gir-scroll{flex:1;min-height:0;overflow:auto;position:relative;scrollbar-width:thin}' +
'.gir-table{display:grid;min-width:max-content}' +
'.gir-head{position:sticky;top:0;z-index:3;display:flex;background:#fff;border-bottom:1px solid #d1d4cd}' +
'.gir-th,.gir-td{box-sizing:border-box;padding:0 8px;display:flex;align-items:center;flex:none;min-width:0}' +
'.gir-th{height:34px;font-size:11.5px;font-weight:700;color:#6d766e;text-transform:uppercase;letter-spacing:.02em}' +
'.gir-th button{all:unset;cursor:pointer;display:flex;gap:4px;align-items:center;width:100%;min-height:24px}' +
'.gir-td{height:32px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}' +
'.gir-td > *{overflow:hidden;text-overflow:ellipsis}' +
'.gir-td.num,.gir-th.num{justify-content:flex-end;font-variant-numeric:tabular-nums}' +
'.gir-tr{display:flex;position:absolute;inset-inline:0;border-bottom:1px solid #eef0ec;background:#fff}' +
'.gir-td a{color:#4f6b52}' +
'.gir-empty{padding:24px;text-align:center;color:#6d766e}',
'gi-resources',
)
</script>
<template>
<section class="gir" data-region="resources" :aria-label="T('gospelInternet.res.aria', 'Gospel resources for the languages in scope')">
<div class="gir-bar">
<span class="gir-count" data-resources-total>{{ T('gospelInternet.res.count', '{n} resources in {langs} of {all} languages in scope', { n: fmt(rows.length), langs: fmt(langsListed), all: fmt(langsInScope) }) }}</span>
<span class="gir-note">{{ T('gospelInternet.res.note', 'A language with no row here has nothing found in the sources checked.') }}</span>
<span style="flex:1"></span>
<span role="status" class="gir-note">{{ status }}</span>
<button type="button" class="gir-btn" data-export-resources @click="exportCsv">{{ T('dashboard.export_csv', 'Export CSV') }}</button>
</div>
<div ref="scroller" class="gir-scroll" role="table" :aria-rowcount="rows.length + 1" @scroll="scrollTop = $event.target.scrollTop">
<div class="gir-table">
<div class="gir-head" role="row">
<div v-for="c in COLS" :key="c.key" class="gir-th" :class="{ num: c.num }" :style="{ width: c.w + 'px' }" role="columnheader"
:aria-sort="sort.key !== c.key ? 'none' : sort.dir > 0 ? 'ascending' : 'descending'">
<button type="button" @click="sortBy(c)">{{ c.label }}<span aria-hidden="true">{{ sort.key === c.key ? (sort.dir > 0 ? '▲' : '▼') : '' }}</span></button>
</div>
</div>
<div :style="{ height: rows.length * ROW_H + 'px', position: 'relative' }" role="rowgroup">
<div v-for="(r, i) in slice" :key="r.iso + '|' + r.url + '|' + (first + i)" class="gir-tr" role="row" :style="{ top: (first + i) * ROW_H + 'px' }">
<div class="gir-td" :style="{ width: COLS[0].w + 'px' }" role="cell"><bdi :title="r.language">{{ r.language }} ({{ r.iso }})</bdi></div>
<div class="gir-td" :style="{ width: COLS[1].w + 'px' }" role="cell"><span>{{ r.type }}</span></div>
<div class="gir-td" :style="{ width: COLS[2].w + 'px' }" role="cell">
<a v-if="safeHref(r.url)" :href="safeHref(r.url)" target="_blank" rel="noopener noreferrer" :title="r.resource"><bdi>{{ r.resource }}</bdi></a>
<bdi v-else>{{ r.resource }}</bdi>
</div>
<div class="gir-td" :style="{ width: COLS[3].w + 'px' }" role="cell"><bdi :title="r.scope">{{ r.scope }}</bdi></div>
<div class="gir-td" :style="{ width: COLS[4].w + 'px' }" role="cell"><span>{{ r.source }}</span></div>
<div class="gir-td num" :style="{ width: COLS[5].w + 'px' }" role="cell">{{ fmt(r.groups) }}</div>
</div>
</div>
<div v-if="board.resources.value && !rows.length" class="gir-empty">{{ T('gospelInternet.res.empty', 'Nothing found for the languages in scope.') }}</div>
<div v-else-if="!board.resources.value" class="gir-empty">{{ T('grid.loading', 'Loading…') }}</div>
</div>
</div>
</section>
</template>

View file

@ -0,0 +1,333 @@
<script setup>
/**
* GiTree.vue — the LANGUAGE TREE, on a whiteboard: every family our people groups belong to, as a radial
* tree (family -> branches -> languages -> dialects), the families packed like cells in a dish.
*
* WHY THE MAP ENGINE AND NOT D3/SVG (Driver, 2026-09-27: "snappy, lightweight, lower definition when
* zoomed out"): the same MapLibre / Mapbox the board already loads draws the tree on the GPU. It tiles the
* GeoJSON per zoom level, so zoomed out it draws LESS; its label placement never lets two labels overlap
* and shows them in priority order as you zoom (families, then languages, then branches, then dialects).
* SVG keeps every circle and label in the page at every zoom, which is what makes a big D3 tree slow.
* The layout is computed at BUILD time (scripts/build-language-tree.mjs), so the device does none.
*
* COLOURS follow gospel-access, per language: green = a New Testament or full Bible found; yellow =
* something found but no New Testament; red = nothing found. A family or branch shows the WORST colour of
* the in-scope languages below it. A dialect carries its language's colour, lighter: no source catalogues
* resources by dialect.
*
* THE KIT'S CHANNELS: clicking a node NARROWS the board to it (the language facet, multi-value, through
* the bus's one predicate); a hovered grid row points at its language's node (hover never moves the
* camera); a committed selection frames its languages once, and jumps under reduced motion.
*/
import { ref, shallowRef, computed, watch, onMounted, onBeforeUnmount, markRaw } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { labelFont } from '@map/maps/label-font.js'
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { createBoardMap, reducedMotion } from '../boardEngine.js'
import { treeScope, TREE_COLOR } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const el = ref(null)
const mapRef = shallowRef(null)
const err = ref('')
const ready = ref(false)
const hoverId = ref('')
const legendOpen = ref(true)
let unmounted = false
let ro = null
const ix = computed(() => board.tree.value)
const scope = computed(() => ix.value ? treeScope(ix.value, board.filtered.value) : null)
const fmt = (n) => (Number(n) || 0).toLocaleString()
// ── GeoJSON from the tree + the scope ──────────────────────────────────────────────────────────────
const LEVEL_NAME = computed(() => ({
f: T('gospelInternet.tree.family', 'Family'), b: T('gospelInternet.tree.branch', 'Branch'),
l: T('gospelInternet.tree.language', 'Language'), d: T('gospelInternet.tree.dialect', 'Dialect'),
}))
function nodeFC() {
const t = ix.value, sc = scope.value
return { type: 'FeatureCollection', features: t.nodes.map((n) => {
const s = sc.get(n.id)
return { type: 'Feature', geometry: { type: 'Point', coordinates: n.c },
properties: { id: n.id, n: n.n, lv: n.lv, col: s.col || 'none', inh: n.inh ? 1 : 0, m: s.m, R: n.R || 0,
root: n.p ? 0 : 1,
// label priority: families first, then languages with the most groups (lower sorts first)
k: (n.lv === 'f' || !n.p ? 0 : n.lv === 'l' ? 1000 : n.lv === 'b' ? 2000 : 3000) - Math.min(999, s.groups) } }
}) }
}
function linkFC() {
const t = ix.value, sc = scope.value
const feats = []
for (const n of t.nodes) {
if (!n.p) continue
const p = t.byId.get(n.p)
feats.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: [p.c, n.c] }, properties: { m: sc.get(n.id).m, d: n.lv === 'd' ? 1 : 0 } })
}
return { type: 'FeatureCollection', features: feats }
}
// A circle radius given in whiteboard units (fraction of the Web Mercator square) grows 2x per zoom
// level: a top-level exponential interpolate with a data-driven stop value is valid on both engines.
const dishRadius = ['interpolate', ['exponential', 2], ['zoom'], 0, ['*', ['get', 'R'], 512], 22, ['*', ['get', 'R'], 512 * 2 ** 22]]
const colorExpr = ['match', ['get', 'col'], 'g', TREE_COLOR.g, 'y', TREE_COLOR.y, 'r', TREE_COLOR.r, '#9aa2ad']
const nodeRadius = ['interpolate', ['linear'], ['zoom'],
0, ['match', ['get', 'lv'], 'f', 4, 'l', 1.6, 'b', 0.9, 0.7],
4, ['match', ['get', 'lv'], 'f', 9, 'l', 4.5, 'b', 2.4, 1.8],
8, ['match', ['get', 'lv'], 'f', 14, 'l', 8, 'b', 4, 3.5]]
const nodeRadiusPlus = (k) => ['interpolate', ['linear'], ['zoom'],
0, ['+', k, ['match', ['get', 'lv'], 'f', 4, 'l', 1.6, 'b', 0.9, 0.7]],
4, ['+', k, ['match', ['get', 'lv'], 'f', 9, 'l', 4.5, 'b', 2.4, 1.8]],
8, ['+', k, ['match', ['get', 'lv'], 'f', 14, 'l', 8, 'b', 4, 3.5]]]
function addLayers(map) {
map.addSource('gt-nodes', { type: 'geojson', data: nodeFC(), promoteId: 'id' })
map.addSource('gt-links', { type: 'geojson', data: linkFC() })
// the dish around each family
map.addLayer({ id: 'gt-dish', type: 'circle', source: 'gt-nodes', filter: ['==', ['get', 'root'], 1], paint: {
'circle-radius': dishRadius, 'circle-color': '#ffffff', 'circle-opacity': 0.55,
'circle-stroke-color': '#d1d4cd', 'circle-stroke-width': 1, 'circle-pitch-alignment': 'map',
} })
map.addLayer({ id: 'gt-links', type: 'line', source: 'gt-links', paint: {
'line-color': '#8d968e', 'line-width': ['interpolate', ['linear'], ['zoom'], 0, 0.3, 6, 1],
'line-opacity': ['case', ['==', ['get', 'm'], 1], ['case', ['==', ['get', 'd'], 1], 0.35, 0.6], 0.12],
} })
map.addLayer({ id: 'gt-nodes', type: 'circle', source: 'gt-nodes', paint: {
'circle-radius': nodeRadius,
'circle-color': colorExpr,
// a dialect's colour is its language's, so it is drawn lighter; out of scope fades, never vanishes
'circle-opacity': ['case', ['==', ['get', 'm'], 0], 0.15, ['==', ['get', 'inh'], 1], 0.5, 0.95],
'circle-stroke-color': '#ffffff', 'circle-stroke-width': ['match', ['get', 'lv'], 'f', 1.5, 0.6],
}, layout: { 'circle-sort-key': ['-', 0, ['get', 'k']] } })
map.addLayer({ id: 'gt-sel', type: 'circle', source: 'gt-nodes', filter: ['==', ['get', 'id'], ''], paint: {
'circle-radius': nodeRadiusPlus(4), 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': '#3b463d', 'circle-stroke-width': 2.5,
} })
map.addLayer({ id: 'gt-hover', type: 'circle', source: 'gt-nodes', filter: ['==', ['get', 'id'], ''], paint: {
'circle-radius': nodeRadiusPlus(3), 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': '#e8a33d', 'circle-stroke-width': 2.5,
} })
// labels, one layer per level so each level has its own entry zoom; placement never overlaps labels
const LABELS = [
{ lv: ['f'], min: 0, size: [0, 11, 5, 15], weight: 'bold', offset: 1.1 },
{ lv: ['l'], min: 2.6, size: [2.6, 10, 7, 13], weight: 'regular', offset: 0.8 },
{ lv: ['b'], min: 3.4, size: [3.4, 9, 7, 11], weight: 'regular', offset: 0.7, muted: true },
{ lv: ['d'], min: 4.6, size: [4.6, 9, 8, 11], weight: 'regular', offset: 0.7, muted: true },
]
for (const L of LABELS) {
map.addLayer({ id: 'gt-lab-' + L.lv[0], type: 'symbol', source: 'gt-nodes', minzoom: L.min,
filter: L.lv[0] === 'f' ? ['==', ['get', 'root'], 1] : ['all', ['==', ['get', 'root'], 0], ['==', ['get', 'lv'], L.lv[0]]],
layout: {
'text-field': ['get', 'n'], 'text-size': ['interpolate', ['linear'], ['zoom'], ...L.size],
'text-anchor': 'top', 'text-offset': [0, L.offset], 'text-max-width': 9, 'symbol-sort-key': ['get', 'k'],
...labelFont(map, { weight: L.weight, mapbox: L.weight === 'bold' ? ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'] : ['DIN Offc Pro Regular', 'Arial Unicode MS Regular'] }),
},
paint: {
'text-color': L.muted ? '#6d766e' : '#3b463d', 'text-halo-color': '#f3f3f1', 'text-halo-width': 1.4,
'text-opacity': ['case', ['==', ['get', 'm'], 0], 0.35, 1],
} })
}
map.on('click', 'gt-nodes', (e) => {
const f = e.features && e.features[0]
if (!f) return
const id = f.properties.id
board.setTreeNode(board.treeNode.value === id ? '' : id)
})
map.on('mousemove', 'gt-nodes', (e) => {
map.getCanvas().style.cursor = 'pointer'
const f = e.features && e.features[0]
hoverId.value = f ? f.properties.id : ''
})
map.on('mouseleave', 'gt-nodes', () => { map.getCanvas().style.cursor = ''; hoverId.value = '' })
}
// ── scope, selection, hover -> the whiteboard ────────────────────────────────────────────────────────
function repaint() {
const map = mapRef.value
if (!map || !ready.value) return
try { map.getSource('gt-nodes').setData(nodeFC()); map.getSource('gt-links').setData(linkFC()) } catch (_) {}
}
watch(scope, repaint)
watch(() => board.treeNode.value, (id) => {
const map = mapRef.value
if (!map || !ready.value) return
map.setFilter('gt-sel', ['==', ['get', 'id'], id || ''])
if (id) frame(subtreeCoords(id))
})
// the pointer on a grid row (a GROUP) points at that group's language here; never a camera move
const hoverNode = computed(() => {
if (hoverId.value) return hoverId.value
const slug = bus.hoverKey.value
const row = slug && board.bySlug.value.get(slug)
return (row && ix.value && ix.value.nodeOfIso.get(row.iso639_3)) || ''
})
watch(hoverNode, (id) => { const map = mapRef.value; if (map && ready.value) map.setFilter('gt-hover', ['==', ['get', 'id'], id || '']) })
// a committed selection frames its groups' languages, once (glue to useSelectionCamera's contract)
let camTimer = null
watch(() => bus.selectionVersion.value, () => {
if (camTimer) clearTimeout(camTimer)
camTimer = setTimeout(() => {
camTimer = null
if (!ix.value || !bus.selectionSet.size) return
const coords = []
for (const s of bus.selectionSet) {
const r = board.bySlug.value.get(s)
const id = r && ix.value.nodeOfIso.get(r.iso639_3)
if (id) coords.push(ix.value.byId.get(id).c)
}
frame(coords)
}, 150)
})
function subtreeCoords(id) {
const t = ix.value, out = []
const walk = (k) => { out.push(t.byId.get(k).c); for (const c of t.kids.get(k) || []) walk(c) }
walk(id)
return out
}
function frame(coords) {
const map = mapRef.value
if (!map || !coords.length) return
let w = 180, s = 90, e = -180, n = -90
for (const [x, y] of coords) { w = Math.min(w, x); e = Math.max(e, x); s = Math.min(s, y); n = Math.max(n, y) }
const duration = reducedMotion() ? 0 : 900
try {
if (e - w < 1e-4 && n - s < 1e-4) {
const z = Math.max(map.getZoom(), 5)
duration ? map.easeTo({ center: [w, s], zoom: z, duration, essential: true }) : map.jumpTo({ center: [w, s], zoom: z })
} else map.fitBounds([[w, s], [e, n]], { padding: 60, maxZoom: 7, duration, essential: true })
} catch (_) { /* a degenerate frame never breaks the board */ }
}
// ── mount (only when the tree is shown: the tree data loads on first use) ────────────────────────────
async function build() {
const t = await board.loadTree()
if (unmounted || !el.value || mapRef.value) return
const made = await createBoardMap(el.value, board.profileConfig.value, { whiteboard: true, boundaries: false, center: [0, 0], zoom: 1 })
if (made.error) { err.value = T('messages.engineMissing', `The ${made.error} map engine did not load.`, { engine: made.error }); return }
if (unmounted) { try { made.map.remove() } catch (_) {} return }
const map = made.map
mapRef.value = markRaw(map)
addLayers(map)
ready.value = true
// frame the whole tapestry once, instantly (an opening view is not a motion the reader asked for)
const all = t.nodes.filter((n) => !n.p).flatMap((n) => {
const R = (n.R || 0) * 360
return [[n.c[0] - R, n.c[1]], [n.c[0] + R, n.c[1]]]
})
let w = 180, e = -180
for (const [x] of all) { w = Math.min(w, x); e = Math.max(e, x) }
const lat = Math.min(80, (e - w) / 2)
try { map.fitBounds([[w, -lat], [e, lat]], { padding: 20, duration: 0 }) } catch (_) {}
if (board.treeNode.value) { map.setFilter('gt-sel', ['==', ['get', 'id'], board.treeNode.value]); frame(subtreeCoords(board.treeNode.value)) }
try {
window.__giTree = map
window.__giTreeState = () => ({ provider: made.provider, nodes: t.nodes.length,
renderedNodes: map.queryRenderedFeatures({ layers: ['gt-nodes'] }).length,
renderedLabels: map.queryRenderedFeatures({ layers: ['gt-lab-f', 'gt-lab-l', 'gt-lab-b', 'gt-lab-d'] }).length,
treeNode: board.treeNode.value })
} catch (_) {}
}
onMounted(() => {
if (el.value && typeof ResizeObserver !== 'undefined') {
// the engines only follow WINDOW resizes; a dragged rail or an un-hidden panel must resize the canvas
ro = new ResizeObserver(() => { try { mapRef.value && mapRef.value.resize() } catch (_) {} })
ro.observe(el.value)
}
build().catch((e) => { err.value = String(e && e.message || e) })
})
onBeforeUnmount(() => {
unmounted = true
if (camTimer) clearTimeout(camTimer)
try { ro && ro.disconnect() } catch (_) {}
try { mapRef.value && mapRef.value.remove() } catch (_) {}
})
// ── the info card: the hovered node, else the selected one ─────────────────────────────────────────
const card = computed(() => {
const id = hoverNode.value || board.treeNode.value
if (!id || !ix.value || !scope.value) return null
const n = ix.value.byId.get(id), s = scope.value.get(id)
if (!n || !s) return null
return { n, s, level: LEVEL_NAME.value[n.lv] || '', selected: id === board.treeNode.value }
})
const totals = computed(() => {
if (!scope.value || !ix.value) return null
const t = { g: 0, y: 0, r: 0 }
for (const n of ix.value.nodes) if (!n.p) { const s = scope.value.get(n.id); t.g += s.g; t.y += s.y; t.r += s.r }
return t
})
const ST_EN = { g: 'New Testament or full Bible found', y: 'Something found, but no New Testament', r: 'Nothing found' }
const citation = computed(() => (ix.value && ix.value.meta && ix.value.meta.citation) || '')
useShadowStyles(
'.gtr{position:relative;width:100%;height:100%;overflow:hidden;background:#f3f3f1}' +
'.gtr-map{position:absolute;inset:0}' +
'.gtr-msg{position:absolute;top:45%;left:0;right:0;text-align:center;font-size:13px;color:#6d766e}' +
'.gtr-card,.gtr-legend{position:absolute;z-index:4;background:rgba(255,255,255,.96);color:#3b463d;border:1px solid #d1d4cd;' +
'border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.10);font:12px/1.4 Roboto,-apple-system,"Segoe UI",sans-serif}' +
'.gtr-card{inset-block-start:10px;inset-inline-start:10px;padding:8px 10px;max-width:min(300px,calc(100% - 64px))}' +
'.gtr-card h4{margin:0 0 2px;font-size:13.5px}' +
'.gtr-lvl{font-size:11px;color:#6d766e;margin-bottom:5px}' +
'.gtr-counts{display:flex;gap:10px;flex-wrap:wrap;font-variant-numeric:tabular-nums}' +
'.gtr-c{display:inline-flex;align-items:center;gap:4px}' +
'.gtr-dot{width:10px;height:10px;border-radius:50%;flex:none}' +
'.gtr-via{font-size:11px;color:#6d766e;margin-top:4px}' +
'.gtr-card button{margin-top:6px;border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;min-height:26px;padding:2px 10px;font:inherit;cursor:pointer}' +
'.gtr-legend{inset-block-end:10px;inset-inline-start:10px;padding:8px 10px;max-width:min(360px,calc(100% - 64px))}' +
'.gtr-lh{display:flex;align-items:center;font-weight:700;margin-bottom:4px}' +
'.gtr-lh button{margin-inline-start:auto;border:0;background:none;color:#6d766e;cursor:pointer;min-width:24px;min-height:24px;font:inherit}' +
'.gtr-row{display:flex;align-items:flex-start;gap:6px;margin:2px 0}' +
'.gtr-row .gtr-dot{margin-top:3px}' +
'.gtr-levels{margin:6px 0 2px;font-size:11.5px}' +
'.gtr-note{font-size:10.5px;color:#6d766e;margin-top:4px}' +
'.gtr-min{position:absolute;inset-block-end:10px;inset-inline-start:10px;z-index:4;border:1px solid #d1d4cd;background:#fff;color:#3b463d;' +
'border-radius:8px;padding:5px 10px;min-height:28px;font:600 12px Roboto,sans-serif;cursor:pointer}',
'gi-tree',
)
</script>
<template>
<div class="gtr" data-region="tree">
<div ref="el" class="gtr-map" role="region" :aria-label="T('gospelInternet.tree.aria', 'Language tree: every language family of these people groups, coloured by what is found in each language')"></div>
<div v-if="err" class="gtr-msg" role="alert">{{ err }}</div>
<div v-else-if="!ready" class="gtr-msg" role="status">{{ T('grid.loading', 'Loading…') }}</div>
<MapToolbar v-if="mapRef">
<ZoomInButton :map="mapRef" />
<ZoomOutButton :map="mapRef" />
</MapToolbar>
<div v-if="card" class="gtr-card" data-tree-card>
<h4><bdi>{{ card.n.n }}</bdi></h4>
<div class="gtr-lvl">{{ card.level }}<template v-if="card.n.iso"> · {{ card.n.iso }}</template>
<template v-if="card.s.groups"> · {{ T('gospelInternet.tree.groupsN', '{n} people groups', { n: fmt(card.s.groups) }) }}</template></div>
<div v-if="card.n.lv === 'f' || card.n.lv === 'b'" class="gtr-counts">
<span class="gtr-c"><span class="gtr-dot" :style="{ background: TREE_COLOR.r }"></span>{{ fmt(card.s.r) }}</span>
<span class="gtr-c"><span class="gtr-dot" :style="{ background: TREE_COLOR.y }"></span>{{ fmt(card.s.y) }}</span>
<span class="gtr-c"><span class="gtr-dot" :style="{ background: TREE_COLOR.g }"></span>{{ fmt(card.s.g) }}</span>
<span>{{ T('gospelInternet.tree.languagesHere', 'languages here') }}</span>
</div>
<div v-else-if="card.n.st" class="gtr-c"><span class="gtr-dot" :style="{ background: TREE_COLOR[card.n.st] }"></span>{{ T('gospelInternet.tree.st.' + card.n.st, ST_EN[card.n.st]) }}</div>
<div v-if="card.n.inh" class="gtr-via">{{ T('gospelInternet.tree.inherited', 'Colour of its language: no source catalogues resources by dialect.') }}</div>
<div v-if="card.n.via" class="gtr-via">{{ T('gospelInternet.tree.via', 'Also on this path:') }} {{ card.n.via.join(' › ') }}</div>
<button v-if="card.selected" type="button" @click="board.setTreeNode('')">{{ T('controls.clear', 'Clear') }}</button>
</div>
<div v-if="legendOpen" class="gtr-legend" data-tree-legend>
<div class="gtr-lh"><span>{{ T('gospelInternet.tree.legendTitle', 'In their own language') }}</span>
<button type="button" :aria-label="T('gospelInternet.legend.hide', 'Hide the legend')" @click="legendOpen = false">✕</button></div>
<div class="gtr-row"><span class="gtr-dot" :style="{ background: TREE_COLOR.g }"></span><span>{{ T('gospelInternet.tree.st.g', 'New Testament or full Bible found') }}<template v-if="totals"> · {{ fmt(totals.g) }}</template></span></div>
<div class="gtr-row"><span class="gtr-dot" :style="{ background: TREE_COLOR.y }"></span><span>{{ T('gospelInternet.tree.st.y', 'Something found, but no New Testament') }}<template v-if="totals"> · {{ fmt(totals.y) }}</template></span></div>
<div class="gtr-row"><span class="gtr-dot" :style="{ background: TREE_COLOR.r }"></span><span>{{ T('gospelInternet.tree.st.r', 'Nothing found') }}<template v-if="totals"> · {{ fmt(totals.r) }}</template></span></div>
<div class="gtr-levels">{{ T('gospelInternet.tree.levels', 'Family › branch › language › dialect. A family or branch shows the worst colour inside it. A dialect shows its language\'s colour, lighter: resources are catalogued per language, not per dialect. Click a circle to narrow the board to it.') }}</div>
<div class="gtr-note">{{ T('gospelInternet.tree.sources', 'Tree: Glottolog (CC BY 4.0). Resources: eBible, Faith Comes By Hearing, Scripture Earth, PNG.Bible, JESUS Film, Global Recordings and others, keyed by ISO 639-3.') }} {{ citation }}</div>
</div>
<button v-else type="button" class="gtr-min" @click="legendOpen = true">{{ T('gospelInternet.legend.show', 'Legend') }}</button>
</div>
</template>

View file

@ -94,15 +94,22 @@ export function parseQuery(search) {
if (vals.length) filters[key] = vals.length === 1 ? vals[0] : vals
}
const view = VIEWS.includes(q.get('view')) ? q.get('view') : 'board'
return { view, filters, group: q.get('group') || '' }
const panel = q.get('panel') === 'tree' ? 'tree' : 'map'
const tree = /^[a-z0-9-]{3,40}$/.test(q.get('tree') || '') ? q.get('tree') : ''
// A tree node narrows by LANGUAGE (its ISO codes); the URL carries the node, not the code list.
if (tree) delete filters.language
return { view, filters, group: q.get('group') || '', panel, tree }
}
/** The inverse: filterSet + view + group -> a query string (no leading '?'), stable key order. */
export function buildQuery({ view, filterSet, group, keep } = {}) {
export function buildQuery({ view, filterSet, group, keep, panel, tree } = {}) {
const q = new URLSearchParams(keep || '')
for (const name of [...Object.keys(URL_KEYS), 'view', 'group']) q.delete(name)
for (const name of [...Object.keys(URL_KEYS), 'view', 'group', 'panel', 'tree']) q.delete(name)
if (view && view !== 'board') q.set('view', view)
if (panel === 'tree') q.set('panel', 'tree')
if (tree) q.set('tree', tree)
for (const [name, key] of Object.entries(URL_KEYS)) {
if (tree && key === 'language') continue
const v = filterSet?.[key]
if (v === undefined || v === null || v === '') continue
q.set(name, Array.isArray(v) ? v.join('|') : String(v))
@ -193,6 +200,53 @@ export function countriesBy(rows, countries, pick) {
return [...m.entries()].map(([value, n]) => ({ value, n })).sort((a, b) => (a.value === '') - (b.value === '') || b.n - a.n)
}
// ── the language tree ──────────────────────────────────────────────────────────────────────────────
export const TREE_COLOR = { g: '#1f8f5f', y: '#e3a82b', r: '#b42318' }
/** Index a tree document once: children lists and, per node, every roster ISO code at or below it. */
export function indexTree(doc) {
const nodes = doc?.nodes || []
const byId = new Map(nodes.map((n) => [n.id, n]))
const kids = new Map()
for (const n of nodes) if (n.p) { if (!kids.has(n.p)) kids.set(n.p, []); kids.get(n.p).push(n.id) }
const isos = new Map()
// nodes are written parents-first, so walking backwards sees every child before its parent
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i]
const set = new Set(n.iso && n.g ? [n.iso] : [])
for (const k of kids.get(n.id) || []) for (const x of isos.get(k)) set.add(x)
isos.set(n.id, set)
}
const nodeOfIso = new Map()
for (const n of nodes) if (n.iso && n.g && !nodeOfIso.has(n.iso)) nodeOfIso.set(n.iso, n.id)
return { nodes, byId, kids, isos, nodeOfIso }
}
/**
* Per node, for the rows in scope: how many roster LANGUAGES of each colour sit at or below it, how many
* groups, and the colour to draw. A language or dialect draws its own colour (a dialect's is inherited and
* flagged); a family or branch draws the WORST colour among the in-scope languages below it, so the gap
* is what the eye finds. `m` = 1 when anything at or below the node is in scope (else the node fades).
*/
export function treeScope(ix, rows) {
const groupsByIso = new Map()
for (const r of rows) groupsByIso.set(r.iso639_3, (groupsByIso.get(r.iso639_3) || 0) + 1)
const out = new Map()
for (let i = ix.nodes.length - 1; i >= 0; i--) {
const n = ix.nodes[i]
const s = { g: 0, y: 0, r: 0, groups: 0 }
if (n.iso && n.g && groupsByIso.has(n.iso)) { s[n.st] += 1; s.groups += groupsByIso.get(n.iso) }
for (const k of ix.kids.get(n.id) || []) { const c = out.get(k); s.g += c.g; s.y += c.y; s.r += c.r; s.groups += c.groups }
const worst = s.r ? 'r' : s.y ? 'y' : s.g ? 'g' : ''
s.col = (n.lv === 'l' || n.lv === 'd') && n.st ? n.st : worst || n.st || ''
s.m = s.groups > 0 ? 1 : 0
out.set(n.id, s)
}
// a dialect is "in scope" when its language is (dialects carry no groups of their own)
for (const n of ix.nodes) if (n.lv === 'd' && !n.g) { const p = out.get(n.p); if (p) out.get(n.id).m = p.m }
return out
}
/** Stacked series: access_detail within each band (chart 1). */
export function accessByBand(rows, measure = 'groups') {
const out = {}

View file

@ -35,6 +35,12 @@ export function useBoard() {
const view = ref('board')
const sort = ref({ key: 'population', dir: -1 })
const openSlug = ref('') // the group in the detail drawer ('' = closed)
// ── the centre panel: the geographic map or the language tree (loaded on first use) ───────────────
const centre = ref('map')
const tree = shallowRef(null) // indexTree(doc) + { meta }
const resources = shallowRef(null) // { meta, rows } — gospel links for the board's languages
const treeNode = ref('') // the tree node the board is narrowed to ('' = none)
let treeLoading = null
// Every panel's row set. The filterSet reads inside rowMatchesFilters are reactive, so this recomputes
// on any facet change; searchTerm is read explicitly for the same reason.
@ -68,6 +74,49 @@ export function useBoard() {
}
}
async function loadTree() {
if (tree.value) return tree.value
if (!treeLoading) {
const base = resolveAssetsBase(cfg())
treeLoading = Promise.all(['gospel-internet-tree.json', 'gospel-internet-resources.json'].map((f) =>
fetch(`${base}/tables/${f}`).then((r) => { if (!r.ok) throw new Error(`${f} HTTP ${r.status}`); return r.json() })))
.then(([t, res]) => {
tree.value = markRaw({ ...M.indexTree(t), meta: t.meta || {} })
resources.value = markRaw(res)
if (pendingTree) { setTreeNode(pendingTree); pendingTree = '' }
return tree.value
})
.catch((e) => { treeLoading = null; throw e })
}
return treeLoading
}
function setCentre(mode) {
centre.value = mode === 'tree' ? 'tree' : 'map'
if (centre.value === 'tree') loadTree().catch((e) => { error.value = String(e?.message || e) })
}
/**
* Narrow the board to one tree node: the rows whose language is at or below it. It goes through the
* EXISTING language facet as a multi-value filter (D1), so every panel, count and export follows by the
* bus's one predicate; the node id is remembered only to show and share which node it was.
*/
let treeSetKey = ''
function setTreeNode(id) {
const ix = tree.value
if (!id || !ix || !ix.byId.has(id)) { treeNode.value = ''; treeSetKey = ''; if (id === '') bus.setFilter('language', null); return }
const onBoard = new Set(all.value.map((r) => r.iso639_3))
const isos = [...ix.isos.get(id)].filter((i) => onBoard.has(i)).sort()
treeNode.value = id
// No language of this node is on the list: narrow to nothing rather than silently to everything.
const value = isos.length ? isos : ['(none)']
treeSetKey = value.join('|')
bus.setFilter('language', value)
}
// Another control changed or cleared the language facet: the node no longer describes the scope.
watch(() => { const v = bus.filterSet.language; return v == null ? '' : [].concat(v).join('|') }, (k) => {
if (treeNode.value && k !== treeSetKey) { treeNode.value = ''; treeSetKey = '' }
})
let pendingTree = ''
// ── views are PRESETS over the one bus ────────────────────────────────────────────────────────────
function setView(v, { keepFilters = false } = {}) {
const next = M.VIEWS.includes(v) ? v : 'board'
@ -102,6 +151,9 @@ export function useBoard() {
for (const [k, v] of Object.entries({ ...hostFilters, ...q.filters })) if (k in M.FACET_ACC) bus.setFilter(k, v)
const group = q.group || cfg().group || ''
if (group) pendingGroup = group
const panel = q.panel === 'tree' || cfg().panel === 'tree' ? 'tree' : 'map'
if (q.tree) pendingTree = q.tree
setCentre(panel)
}
let pendingGroup = ''
watch(all, () => {
@ -110,13 +162,14 @@ export function useBoard() {
})
function syncUrl() {
if (!writeUrl.value || typeof history === 'undefined') return
const qs = M.buildQuery({ view: view.value, filterSet: bus.filterSet, group: openSlug.value, keep: location.search })
const qs = M.buildQuery({ view: view.value, filterSet: bus.filterSet, group: openSlug.value, keep: location.search,
panel: centre.value, tree: treeNode.value })
const next = location.pathname + (qs ? '?' + qs : '') + location.hash
if (next !== location.pathname + location.search + location.hash) {
try { history.replaceState(history.state, '', next) } catch (_) { /* sandboxed */ }
}
}
watch([view, openSlug, () => JSON.stringify(bus.filterSet)], syncUrl)
watch([view, openSlug, centre, treeNode, () => JSON.stringify(bus.filterSet)], syncUrl)
/** A link to one group, for "Copy link to this group" — the board's own page, ?group=<slug>. */
function groupLink(slug) {
@ -132,6 +185,7 @@ export function useBoard() {
const board = {
bus, all, bySlug, filtered, sorted, meta, countries, loading, error, listId, listIsDefault,
view, sort, openSlug, openRow, profileConfig,
centre, tree, resources, treeNode, setCentre, setTreeNode, loadTree,
load, setView, openGroup, closeGroup, readUrl, groupLink,
}
provide('giBoard', board)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -578,7 +578,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk",
"integrity": "sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV",
"elements": [
"gospel-internet-board"
],
@ -594,7 +594,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{
@ -641,6 +641,36 @@
"iframe": "<iframe src=\"./gospel-internet/board/india-china/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
},
{
"id": "language-tree",
"title": "Language tree — which languages have the gospel, family by family",
"apiBaseUrl": "",
"props": {
"profile": "board",
"panel": "tree",
"urlState": "query"
},
"element": "gospel-internet-board",
"iframeUrl": "./gospel-internet/board/language-tree/index.html",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/language-tree/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
},
{
"id": "platforms",
"title": "Platforms — which apps would carry a resource there",
"apiBaseUrl": "",
"props": {
"profile": "board",
"view": "platforms",
"urlState": "query"
},
"element": "gospel-internet-board",
"iframeUrl": "./gospel-internet/board/platforms/index.html",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/platforms/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
},
{
"id": "project-700",
"title": "Project 700 — the list as a partner embed",

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../gospel-internet.js" integrity="sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk" crossorigin="anonymous"></script>
<script src="../gospel-internet.js" integrity="sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>Language tree — which languages have the gospel, family by family · gospel-internet</title>
<link rel="icon" href="data:,">
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, gospel-internet-board { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"board","panel":"tree","urlState":"query"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = false;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("gospel-internet-board");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins.
// 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token
// endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The
// element decides the same way from the token this page hands it, so the two cannot disagree.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

View file

@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>Platforms — which apps would carry a resource there · gospel-internet</title>
<link rel="icon" href="data:,">
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, gospel-internet-board { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading token…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"board","view":"platforms","urlState":"query"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = false;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("gospel-internet-board");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins.
// 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token
// endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The
// element decides the same way from the token this page hands it, so the two cannot disagree.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -578,7 +578,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk",
"integrity": "sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV",
"elements": [
"gospel-internet-board"
],
@ -594,7 +594,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-zfPfDHkeu1gdc8daC9CxNhM5HuBpUfrpgyT3IXYIC2CPgNlJmoBFfGLtRjPZTknk\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-ygdHnHizAngG0yDBLAjcSZoOuIdIFROhcdjNcV2dtX8P/v374gXby4NK8BdCPvpV\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{
@ -641,6 +641,36 @@
"iframe": "<iframe src=\"./gospel-internet/board/india-china/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
},
{
"id": "language-tree",
"title": "Language tree — which languages have the gospel, family by family",
"apiBaseUrl": "",
"props": {
"profile": "board",
"panel": "tree",
"urlState": "query"
},
"element": "gospel-internet-board",
"iframeUrl": "./gospel-internet/board/language-tree/index.html",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/language-tree/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
},
{
"id": "platforms",
"title": "Platforms — which apps would carry a resource there",
"apiBaseUrl": "",
"props": {
"profile": "board",
"view": "platforms",
"urlState": "query"
},
"element": "gospel-internet-board",
"iframeUrl": "./gospel-internet/board/platforms/index.html",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/platforms/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
}
},
{
"id": "project-700",
"title": "Project 700 — the list as a partner embed",