doxa-maps/plugin-designs/gospel-resources/scripts/build-gospel-gaps-table.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

124 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
/**
* build-gospel-gaps-table.mjs — the groups with NOTHING found, in an exportable form.
*
* WHAT WAS MISSING. Every table in this bundle lists what EXISTS. The map can now filter
* to the gap and rank its 40 largest, but 40 is a preview and a map is not something you
* can hand to a prayer group or work through in a spreadsheet. There was no exportable
* list of what is missing.
*
* The existing "Gaps and Opportunities" table is not that list and cannot become it: it
* is one row per people group across the curated 2,106-record set, so it cannot see the
* 3,457 groups the pins now know have nothing. It keeps its own job — bridge-language
* analysis for the groups it does cover — and this sits beside it.
*
* WHAT "NOTHING FOUND" MEANS, and the reason every row carries its own provenance: it
* means absent from the pools THIS build checked, never that nothing exists. The checked
* and unchecked pools travel per row so an exported CSV cannot be read as a finding about
* the world — audio in particular has never been searched for ANY group here.
*
* SIGN LANGUAGES ARE FLAGGED because "no Scripture" means something different for them: a
* text pool was never the right place to look. Sorted by population, because a mobiliser
* reading this is choosing where to go.
*/
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 PINS = resolve(REPO, 'data/tables/people-group-pins.json')
const COUNTRY = resolve(REPO, 'data/tables/gospel-by-country.json')
const SLIM = resolve(REPO, 'data/people-groups/people-groups-slim.json')
const OUT = resolve(REPO, 'data/tables/gospel-gaps.json')
const { codes, pins } = JSON.parse(readFileSync(PINS, 'utf8'))
// Country NAMES come from the SAME source the pins do. gospel-by-country knows 170
// countries and the boundaries file 162, while the pins span 242 — so both left rows
// printing a raw ISO3: "CHE" for Switzerland, plus Canada, Taiwan, Timor-Leste, Mexico
// and Argentina. An exported CSV with a bare code in the country column is materially
// worse than one without it.
// people-groups-slim.json carries CountryName on every row and is where the pins were
// built from, so its coverage matches by construction rather than by luck. The curated
// gospel-by-country names still win where it has one — they are the reviewed spelling.
let byIso = {}
const slimRows = (() => {
const d = JSON.parse(readFileSync(SLIM, 'utf8'))
if (Array.isArray(d)) return d
for (const v of Object.values(d)) if (Array.isArray(v) && v[0] && typeof v[0] === 'object') return v
return []
})()
for (const r of slimRows) {
const a = String(r.ISOAlpha3 || '').trim()
const n = String(r.CountryName || '').trim()
if (a && n && !byIso[a]) byIso[a] = n
}
try {
// curated spellings override
for (const c of JSON.parse(readFileSync(COUNTRY, 'utf8')).countries || []) if (c.iso3 && c.country) byIso[c.iso3] = c.country
} catch { /* the slim names already cover every pin */ }
const rows = []
for (const [name, lat, lng, iso3, code, pop, tier] of pins) {
if (tier !== 0) continue
const c = codes[code] || {}
rows.push({
people_group: name,
country: byIso[iso3] || iso3,
iso3,
language: c.l || '',
iso639_3: code || '',
population: pop || 0,
sign_language: c.sg === 1 ? 'yes' : '',
// WHICH KIND OF GAP THIS IS. "1,845 groups have nothing" is four situations wearing one
// number and they need four different responses — the map says so on screen, and an
// exported copy of this table could not say it at all until now. Order matters: a code
// can be both a sign language and private-use (every private-use code here IS one), so
// the more actionable label wins and the meta says they overlap.
gap_kind:
!code ? 'no language code — no pool can index it'
: c.sg === 1 ? 'sign language — pools are key-gated, an access problem'
: /^q[a-t][a-z]$/.test(code) ? 'private-use code — no pool can index it'
: !/\bgrn\b/.test(c.ck || '') ? 'GRN not yet swept — the crawl is still running'
: 'every reachable pool checked — the real translation gap',
// A group with NO ISO 639-3 code cannot be looked up in any pool at all — nothing was
// searched because there was nothing to search WITH. Two blank cells would read as an
// oversight; this says why. One row is in this state: Arab, Gazan, 2.33M people.
checked_pools: c.ck || (code ? '' : 'no language code in the source — no lookup possible'),
unchecked_pools: c.un || (code ? '' : 'no language code in the source — no lookup possible'),
lat,
lng,
})
}
rows.sort((a, b) => b.population - a.population || a.people_group.localeCompare(b.people_group))
const people = rows.reduce((n, r) => n + r.population, 0)
const signRows = rows.filter((r) => r.sign_language).length
const doc = {
meta: {
title: 'Where Nothing Was Found',
grain: 'one row per people group with no Scripture, film or Door43 resource in the pools this build checked',
the_finding: `${rows.length.toLocaleString()} people groups, ${people.toLocaleString()} people. ${signRows} of them speak a sign language.`,
not_proof_of_absence: 'ABSENT FROM THE POOLS CHECKED, never proof that nothing exists. Each row carries its own checked_pools and unchecked_pools so an exported copy cannot be read as a claim about the world.',
audio_never_checked: 'AUDIO HAS NEVER BEEN SEARCHED FOR ANY GROUP ON THIS MAP. GRN is the main audio-gospel pool and needs credentials this build does not have, so for an oral culture the most relevant resource is the one pool nobody has looked in.',
sign_languages: 'A sign language cannot be served by a text translation at all, so a text pool was never the right place to look for the rows flagged sign_language.',
gap_kind_note: 'Four situations share this table and only the last is a translation problem. Sign language and private-use OVERLAP COMPLETELY here — every private-use code in this gap is a sign language — so filtering to gap_kind="sign language" alone understates the Deaf share; add the private-use rows to get the true figure. The "GRN not yet swept" rows will resolve themselves as the crawl proceeds and are not evidence of absence.',
doctrine_boundary: 'Only sources publishing the 66-book canon are searched. Watchtower/JW, Seventh-day Adventist, LDS and other added-canon publishers are excluded by rule, which is why some languages they do publish in appear here.',
no_believer_data: 'This table describes PUBLISHED RESOURCES only. It carries no church, believer or meeting-point data and must never gain any.',
},
columns: [
{ title: 'People group', field: 'people_group', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Language', field: 'language', type: 'string' },
{ title: 'ISO 639-3', field: 'iso639_3', type: 'string' },
{ title: 'Population', field: 'population', type: 'number' },
{ title: 'Sign language', field: 'sign_language', type: 'string' },
{ title: 'Kind of gap', field: 'gap_kind', type: 'string' },
{ title: 'Pools checked', field: 'checked_pools', type: 'string' },
{ title: 'Pools NOT checked', field: 'unchecked_pools', type: 'string' },
],
rows,
}
writeFileSync(OUT, JSON.stringify(doc))
console.log(`rows ${rows.length} people ${people.toLocaleString()} sign-language rows ${signRows}`)
console.log(`wrote ${OUT} (${(readFileSync(OUT).length / 1024).toFixed(0)} KB)`)