342 lines
19 KiB
JavaScript
342 lines
19 KiB
JavaScript
/**
|
|
* build-gospel-by-province.mjs — gospel access BELOW the national border, for the two countries
|
|
* that hold half the roster.
|
|
*
|
|
* WHY ONLY INDIA AND CHINA. 855 of Doxa's 2,107 groups are in India and 154 in China: 47.9% of
|
|
* everything this map draws, rendered until now as two flat fills. A mobiliser choosing between
|
|
* Bihar and Tamil Nadu got nothing from the map. No other country comes close — the third is
|
|
* Sudan at 105 — so this is not the start of a worldwide ADM1 project, it is the two countries
|
|
* where a national border is useless.
|
|
*
|
|
* ⚠ CHINA'S ADM1 FILE IS A SUBSET AND THAT TURNS OUT TO BE THE RIGHT SUBSET. It carries 12
|
|
* provinces, not China's 31+ — Yunnan, Guizhou, Tibet, Sichuan, Xinjiang, Guangxi, Hainan,
|
|
* Hunan, Qinghai, Gansu, Inner Mongolia, Heilongjiang. I expected that to lose groups and
|
|
* measured instead: 152 of 154 Chinese groups fall inside them, and ALL TWELVE are used. The
|
|
* file was cut to the provinces where the unreached peoples actually are.
|
|
*
|
|
* ⚠ AND THE ASSIGNMENT IS ONLY AS GOOD AS THE PIN. A group's province comes from a
|
|
* point-in-polygon test on a coordinate that marks where a PEOPLE is found, not a settlement —
|
|
* 108 groups even share a coordinate with another group. Near a border that point can fall on
|
|
* the wrong side. So every row carries `approximate: true`, the unassigned are COUNTED rather
|
|
* than dropped, and no surface may present a province as a fact about a group.
|
|
* Measured: 9 of 855 Indian groups and 2 of 154 Chinese ones land outside every polygon.
|
|
* They are reported, not hidden.
|
|
*/
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { writeSnapshot } from './_snapshot-guard.mjs'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const REPO = resolve(HERE, '../../..')
|
|
const OUT = resolve(REPO, 'data/tables/gospel-by-province.json')
|
|
const OUT_GROUPS = resolve(REPO, 'data/tables/gospel-by-province-groups.json')
|
|
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
|
|
const BOUND = (f) => JSON.parse(readFileSync(resolve(REPO, 'public/dx-maps/assets/boundaries', f), 'utf8'))
|
|
|
|
const { tiers = {}, codes = {}, bridges = {}, bridgeCandidates = {}, sun } = T('people-group-pins.json')
|
|
const roster = T('doxa-people-groups.json').rows || []
|
|
|
|
/* Ray casting, with holes honoured: a point inside an inner ring is outside the polygon. */
|
|
const ringHas = (ring, x, y) => {
|
|
let inside = false
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1]
|
|
if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)) inside = !inside
|
|
}
|
|
return inside
|
|
}
|
|
const polyHas = (poly, x, y) => {
|
|
if (!ringHas(poly[0], x, y)) return false
|
|
for (let k = 1; k < poly.length; k++) if (ringHas(poly[k], x, y)) return false
|
|
return true
|
|
}
|
|
const featHas = (f, x, y) => {
|
|
const g = f.geometry
|
|
if (!g) return false
|
|
if (g.type === 'Polygon') return polyHas(g.coordinates, x, y)
|
|
if (g.type === 'MultiPolygon') return g.coordinates.some((p) => polyHas(p, x, y))
|
|
return false
|
|
}
|
|
/* A bbox pre-filter: without it this is 1,009 groups x 46 polygons of full ray casting. */
|
|
const bboxOf = (f) => {
|
|
const m = [180, 90, -180, -90]
|
|
const walk = (a) => {
|
|
if (typeof a[0] === 'number') {
|
|
m[0] = Math.min(m[0], a[0]); m[1] = Math.min(m[1], a[1])
|
|
m[2] = Math.max(m[2], a[0]); m[3] = Math.max(m[3], a[1])
|
|
} else a.forEach(walk)
|
|
}
|
|
walk(f.geometry.coordinates)
|
|
return m
|
|
}
|
|
|
|
/* The same Deaf test the leaf and both builders use — three signals, never the sg flag alone. */
|
|
const isDeaf = (code, name) => {
|
|
const c = codes[code]
|
|
return (c && c.sg === 1) || /sign language/i.test((c && c.l) || '') || /\bDeaf\b/i.test(name || '')
|
|
}
|
|
const wayIn = (code, iso3, name) => {
|
|
if (!code || !iso3) return ''
|
|
if (isDeaf(code, name)) return sun ? 'SUN (signed)' : ''
|
|
const b = bridges[`${code}|${iso3}`] || bridgeCandidates[`${code}|${iso3}`]
|
|
return b ? b.l : ''
|
|
}
|
|
|
|
/* ═══ PRAYER, AT THE GRAIN A TEAM IS ACTUALLY SENT TO ═══════════════════════════════════════
|
|
* The country sheet already ends in a prayer move. A country is not where anyone is sent — a state
|
|
* is — so the province carries the same two numbers. The JOIN IS BY NAME+COUNTRY, not by language,
|
|
* because you pray for a people in a place; that is doxa.life's own key and the one place in this
|
|
* build where a name match is correct rather than forbidden.
|
|
*
|
|
* ⚠ THE DENOMINATOR IS THE GROUPS WITH A PRAYER PAGE, never the province. doxa.life publishes a
|
|
* page for 1,723 of the roster, so "nobody praying" can only be said of groups that HAVE one, and
|
|
* both numbers ship so no surface can quote the first without the second.
|
|
*/
|
|
let pray = {}
|
|
try { pray = T('pray-index.json').byKey || {} } catch { /* no index: the columns ship as null, never as zero */ }
|
|
const prayKey = (name, iso3) =>
|
|
`${String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()}|${String(iso3 || '').toUpperCase()}`
|
|
|
|
/* ⚠ INDIA READS THE -full FILE, NOT gb-IND-ADM1.geojson. The unsuffixed file is the UPG-100
|
|
list's own curated partition — 34 listId-tagged units — and this table was built on it for one
|
|
build, which is why eleven groups had no province: they were standing in states the partition
|
|
does not draw. gb-IND-ADM1-full.geojson is the complete ADM1 (36 states, geoBoundaries via
|
|
DataMeet India, CC BY 2.5 IN). A partition of somebody else's list is not a country's states,
|
|
even when the filename looks like it is. */
|
|
const COUNTRIES = [
|
|
{ iso3: 'IND', file: 'gb-IND-ADM1-full.geojson', country: 'India' },
|
|
{ iso3: 'CHN', file: 'gb-CHN-ADM1.geojson', country: 'China' },
|
|
]
|
|
|
|
const rows = []
|
|
const unassigned = []
|
|
const assignments = []
|
|
let provincesAvailable = 0
|
|
for (const c of COUNTRIES) {
|
|
const feats = BOUND(c.file).features.map((f) => ({ f, b: bboxOf(f), name: f.properties.shapeName }))
|
|
provincesAvailable += feats.length
|
|
const groups = roster.filter((r) => r.iso3 === c.iso3 && r.mapped && r.lat != null && r.lng != null)
|
|
const by = new Map()
|
|
for (const r of groups) {
|
|
const x = r.lng, y = r.lat
|
|
let hit = null
|
|
for (const cand of feats) {
|
|
if (x < cand.b[0] || x > cand.b[2] || y < cand.b[1] || y > cand.b[3]) continue
|
|
if (featHas(cand.f, x, y)) { hit = cand.name; break }
|
|
}
|
|
if (!hit) { unassigned.push({ country: c.country, iso3: c.iso3, people_group: r.name, lat: r.lat, lng: r.lng }); continue }
|
|
/* THE PER-GROUP ROW, published beside the aggregate so no other build has to repeat this
|
|
point-in-polygon. gospel-internet was running the identical ray-casting over the identical
|
|
two geometry files and refusing to write unless its counts matched these; a second
|
|
implementation of the same geometry is a second place for it to drift. */
|
|
assignments.push({
|
|
people_group: r.name,
|
|
country: c.country,
|
|
iso3: c.iso3,
|
|
province: hit,
|
|
iso639_3: r.iso639_3 || '',
|
|
population: r.population || 0,
|
|
tier: tiers[r.iso639_3] ?? r.tier ?? 0,
|
|
approximate: true,
|
|
})
|
|
if (!by.has(hit)) by.set(hit, [])
|
|
by.get(hit).push(r)
|
|
}
|
|
for (const [province, list] of by) {
|
|
const zero = list.filter((r) => (tiers[r.iso639_3] ?? r.tier ?? 0) === 0)
|
|
/* ═══ A WAY IN CHOSEN FOR THIS PROVINCE, NOT FOR THE COUNTRY ══════════════════════════
|
|
* The caveat this table used to carry said it plainly: Arunachal Pradesh, in the far
|
|
* north-east, was offered TELUGU — a South Indian language — because the curated bridge
|
|
* table is keyed by country and a curated bridge outranks a computed one. India's computed
|
|
* default is Hindi, defensible nationally and wrong for much of the north-east.
|
|
*
|
|
* So the province answers for itself. The candidates are the languages of groups STANDING
|
|
* IN THIS PROVINCE that already have Scripture AND film (tier 2), ranked by how many people
|
|
* here already hold them. That is a measured neighbour, not a national average: a language
|
|
* with 4 million people in this state is a plausible second language for its neighbours in a
|
|
* way that a language from 1,500 km away is not.
|
|
*
|
|
* It never invents one. A province with no tier-2 language of its own keeps the country-level
|
|
* answer and SAYS so, because "no local bridge exists" is itself worth reading.
|
|
*/
|
|
const localTier2 = new Map()
|
|
for (const r of list) {
|
|
const code = r.iso639_3
|
|
if (!code || (tiers[code] ?? r.tier ?? 0) !== 2) continue
|
|
if (isDeaf(code, r.name)) continue
|
|
const cur = localTier2.get(code) || { code, language: (codes[code] && codes[code].l) || code, people: 0, groups: 0 }
|
|
cur.people += r.population || 0
|
|
cur.groups += 1
|
|
localTier2.set(code, cur)
|
|
}
|
|
const local = [...localTier2.values()].sort((a, b) => b.people - a.people).slice(0, 3)
|
|
|
|
/* Counted over the groups in THIS province that have a prayer page. */
|
|
let prayPages = 0, noPray = 0, noPrayPeople = 0
|
|
let move = null, moveRank = -1
|
|
for (const r of list) {
|
|
const e = pray[prayKey(r.name, r.iso3)]
|
|
if (!e) continue
|
|
prayPages++
|
|
if (e.praying > 0) continue
|
|
noPray++
|
|
noPrayPeople += r.population || 0
|
|
/* THE MOVE: need first, then size — the same order the country sheet uses, so a reader
|
|
drilling from country to province is not handed two different rankings. */
|
|
const rank = ((tiers[r.iso639_3] ?? r.tier ?? 0) === 0 ? 1e12 : 0) + (r.population || 0)
|
|
if (rank > moveRank) {
|
|
moveRank = rank
|
|
move = { name: e.n || r.name, url: e.u, people: r.population || 0, has_nothing: (tiers[r.iso639_3] ?? r.tier ?? 0) === 0 }
|
|
}
|
|
}
|
|
rows.push({
|
|
country: c.country,
|
|
iso3: c.iso3,
|
|
province,
|
|
groups: list.length,
|
|
people: list.reduce((n, r) => n + (r.population || 0), 0),
|
|
zero_access_groups: zero.length,
|
|
zero_access_people: zero.reduce((n, r) => n + (r.population || 0), 0),
|
|
/* Named so a reader of the CSV can act without opening the map. */
|
|
zero_access_examples: zero.sort((a, b) => (b.population || 0) - (a.population || 0))
|
|
.slice(0, 3).map((r) => r.name).join('; '),
|
|
prayer_pages: prayPages,
|
|
/* The URL comes from the prayer index, which took it from doxa.life's OWN listing (a slug is
|
|
never constructed here: pray.doxa.life answers 200 for anything). */
|
|
pray_first: move,
|
|
nobody_praying_groups: noPray,
|
|
nobody_praying_people: noPrayPeople,
|
|
ways_in: local.length
|
|
? local.map((l) => `${l.language} (${l.people.toLocaleString('en-US')} here)`).join('; ')
|
|
: [...new Set(zero.map((r) => wayIn(r.iso639_3, r.iso3, r.name)).filter(Boolean))].join('; '),
|
|
/* WHICH QUESTION THE WAY IN ANSWERED. Without this column the two kinds of answer are
|
|
indistinguishable on the page, and a country-level answer read as a local one is exactly
|
|
how Telugu ends up looking like advice about Arunachal Pradesh. */
|
|
ways_in_basis: local.length ? 'in this province' : 'country-level — no local language here has both Scripture and film',
|
|
ways_in_country: [...new Set(zero.map((r) => wayIn(r.iso639_3, r.iso3, r.name)).filter(Boolean))].join('; '),
|
|
approximate: true,
|
|
})
|
|
}
|
|
}
|
|
rows.sort((a, b) => b.zero_access_groups - a.zero_access_groups || b.groups - a.groups)
|
|
|
|
const assigned = rows.reduce((n, r) => n + r.groups, 0)
|
|
if (!assigned || assigned < 900) {
|
|
console.error(`GOSPEL-BY-PROVINCE: REFUSING TO WRITE — only ${assigned} group(s) assigned.`)
|
|
console.error(' India and China hold 1,009 mapped groups; a collapse this large means the')
|
|
console.error(' geometry or the coordinates changed shape, not that the answer moved.')
|
|
process.exitCode = 1
|
|
} else {
|
|
/* ═══ THE TWO FILES MUST AGREE, ASSERTED HERE ════════════════════════════════════════════
|
|
* The aggregate says a province holds N groups; the per-group file lists them. Published
|
|
* separately (the map fetches only the aggregate, lazily, and has no use for 1,005 rows), which
|
|
* means they CAN disagree — so they are reconciled before either is written. gospel-internet
|
|
* built its own copy of this point-in-polygon and refused to write unless its counts matched
|
|
* ours; it should not have to, and it should not have to trust us either.
|
|
*/
|
|
const perProvince = new Map()
|
|
for (const a of assignments) perProvince.set(`${a.iso3}|${a.province}`, (perProvince.get(`${a.iso3}|${a.province}`) || 0) + 1)
|
|
const disagree = rows.filter((r) => (perProvince.get(`${r.iso3}|${r.province}`) || 0) !== r.groups)
|
|
if (disagree.length || assignments.length !== assigned) {
|
|
console.error(`GOSPEL-BY-PROVINCE: REFUSING TO WRITE — the per-group file and the aggregate disagree on ${disagree.length} province(s); ${assignments.length} assignments vs ${assigned} counted.`)
|
|
process.exit(1)
|
|
}
|
|
writeSnapshot(
|
|
OUT_GROUPS,
|
|
`${JSON.stringify({
|
|
meta: {
|
|
title: 'Which province each people group falls in — India and China',
|
|
why: 'Published so no other build has to repeat this point-in-polygon. gospel-internet ran ' +
|
|
'the identical ray-casting over the identical geometry and could only check itself against ' +
|
|
'our aggregates; a second implementation of the same geometry is a second place for it to drift.',
|
|
generated: new Date().toISOString().slice(0, 10),
|
|
grain: 'one row per PLACED people group; the four that fall outside every polygon are in the aggregate file\'s `unassigned`',
|
|
rows: assignments.length,
|
|
agrees_with: 'gospel-by-province.json — per-province counts and the assigned total are reconciled at build time, and neither file is written if they differ',
|
|
approximate: 'Every row is approximate. The coordinate marks where a PEOPLE is found, not a ' +
|
|
'settlement, and near a border it can fall on the wrong side. No surface may present a ' +
|
|
'province as a fact about a group.',
|
|
},
|
|
columns: [
|
|
{ title: 'People group', field: 'people_group', type: 'string' },
|
|
{ title: 'Country', field: 'country', type: 'string' },
|
|
{ title: 'Province', field: 'province', type: 'string' },
|
|
{ title: 'Language code', field: 'iso639_3', type: 'string' },
|
|
{ title: 'People', field: 'population', type: 'number' },
|
|
{ title: 'Tier', field: 'tier', type: 'number' },
|
|
],
|
|
rows: assignments,
|
|
}, null, 1)}\n`,
|
|
(j) => (j.rows || []).length,
|
|
'province assignment(s)',
|
|
)
|
|
writeSnapshot(
|
|
OUT,
|
|
`${JSON.stringify({
|
|
meta: {
|
|
title: 'Gospel access by province — India and China',
|
|
why: 'These two countries hold 47.9% of Doxa\'s roster (855 + 154 of 2,107). No other ' +
|
|
'country is close; the third is Sudan at 105. A national fill cannot answer "where would ' +
|
|
'a team go" for half the map.',
|
|
generated: new Date().toISOString().slice(0, 10),
|
|
provinces_with_groups: rows.length,
|
|
provinces_available: provincesAvailable,
|
|
groups_assigned: assigned,
|
|
groups_unassigned: unassigned.length,
|
|
per_group_file: 'gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time',
|
|
india_note: 'India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet ' +
|
|
'India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is ' +
|
|
'the UPG-100 list\'s curated 34-unit partition, and eleven groups came out with no ' +
|
|
'province because they stood in states that partition does not draw. Switching the file ' +
|
|
'moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names ' +
|
|
'into India\'s own states.',
|
|
unassigned_note: 'Four remain, each one legible rather than lost. Great Andamanese and ' +
|
|
'the Sentinelese sit on small islands whose simplified coastline the point falls just ' +
|
|
'outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a ' +
|
|
'missing province. Jing and Pana are on China\'s Vietnam and Laos borders, outside the ' +
|
|
'twelve-province subset. They are listed in `unassigned` with their coordinates.',
|
|
china_note: 'China\'s ADM1 file carries 12 provinces, not 31+. Measured rather than ' +
|
|
'assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the ' +
|
|
'file is cut to the provinces where the unreached peoples are.',
|
|
ways_in: 'Chosen FOR THE PROVINCE where the province can answer: the languages of groups ' +
|
|
'standing in it that already have Scripture AND film, ranked by the people here who ' +
|
|
'already hold them. The column says which basis was used. This replaces a country-level ' +
|
|
'answer that produced a measured absurdity — Arunachal Pradesh, in the far north-east, ' +
|
|
'was offered TELUGU, because the curated bridge table is keyed by country and a curated ' +
|
|
'bridge outranks a computed one. A province with no tier-2 language of its own still ' +
|
|
'falls back to the country answer, and ways_in_basis says so rather than hiding it. ' +
|
|
'It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a ' +
|
|
'question for someone on the ground.',
|
|
approximate: 'A province is assigned by point-in-polygon on the group\'s coordinate, and ' +
|
|
'that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the ' +
|
|
'roster even share a coordinate with another group. Near a border the point can fall on ' +
|
|
'the wrong side. Every row is flagged approximate and the unassigned are counted, never ' +
|
|
'dropped. No surface may present a province as a fact about a group.',
|
|
},
|
|
columns: [
|
|
{ title: 'Country', field: 'country', type: 'string' },
|
|
{ title: 'Province', field: 'province', type: 'string' },
|
|
{ title: 'People groups', field: 'groups', type: 'number' },
|
|
{ title: 'People', field: 'people', type: 'number' },
|
|
{ title: 'Groups with nothing', field: 'zero_access_groups', type: 'number' },
|
|
{ title: 'People with nothing', field: 'zero_access_people', type: 'number' },
|
|
{ title: 'Largest with nothing', field: 'zero_access_examples', type: 'string' },
|
|
{ title: 'Nobody praying', field: 'nobody_praying_groups', type: 'number' },
|
|
{ title: 'Of those with a prayer page', field: 'prayer_pages', type: 'number' },
|
|
{ title: 'People nobody is praying for', field: 'nobody_praying_people', type: 'number' },
|
|
{ title: 'Ways in', field: 'ways_in', type: 'string' },
|
|
{ title: 'Basis', field: 'ways_in_basis', type: 'string' },
|
|
{ title: 'Ways in (country-level)', field: 'ways_in_country', type: 'string' },
|
|
{ title: 'Placement approximate', field: 'approximate', type: 'string' },
|
|
],
|
|
rows,
|
|
unassigned,
|
|
}, null, 1)}\n`,
|
|
(j) => (j.rows || []).length,
|
|
'province row(s)',
|
|
)
|
|
console.log(`gospel-by-province: ${rows.length} province(s) with groups, ${assigned} assigned, ${unassigned.length} unassigned`)
|
|
console.log(` zero-access provinces: ${rows.filter((r) => r.zero_access_groups).length}`)
|
|
console.log(`wrote ${OUT}`)
|
|
}
|