The gap by country is now a table you can export, not just a sheet you can read

gospel-by-country carries 242 countries with real per-country counts, and the map uses
them for the choropleth and the "where the gap concentrates" sheet. The tables profile
had no country table at all, so those numbers lived only inside the map — readable, not
exportable — while every other tab there offers CSV and JSON. A mobiliser deciding where
to send a team cannot work from a sheet they have to screenshot.

Derived, not a second source: every row is read out of gospel-by-country, which is itself
refreshed from the pin index, so the table and the map cannot disagree.

Three columns because they rank differently and each is somebody's question — groups with
nothing, people with nothing, and the share of the country each represents. The default
sort is by groups, and the share column is what makes rows like Vanuatu legible: 79 groups
with nothing out of 114, 69.3%, which ranks ninth by count and first by share. Region is
carried through so the table can be grouped the same way the map's region tab ranks.

Curated columns come through as-is and stay null where the curated analysis does not cover
the country — null, never zero, because zero is a claim this build cannot make.

Verified against the PIN INDEX rather than against the table being tested: 242 countries
with pins, 2,901 groups with nothing, worst is PNG at 469. The rendered tab reports 242
rows and its first row reads Papua New Guinea / PNG / Oceania / 831 / 469 / 56.4, with
650 real cells. No page errors. Screenshot read.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 15:34:53 +00:00
parent c0a1cec24a
commit af6f343d81
13 changed files with 7545 additions and 13 deletions

3711
data/tables/country-gap.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -73,6 +73,10 @@
{
"name": "gospel-gaps",
"rows": 2901
},
{
"name": "country-gap",
"rows": 242
}
]
}

View file

@ -41,6 +41,11 @@ const TABS = [
// that one is one row per group across the curated 2,106-record set and structurally
// cannot see these groups.
{ id: 'nothing', label: 'Where Nothing Was Found', file: 'gospel-gaps' },
// The same gap, aggregated to the place a team gets sent. It existed only inside the
// map's sheet — readable, not exportable — while every other tab here offers CSV and
// JSON. Derived from gospel-by-country, which is refreshed from the pin index, so it
// cannot drift from the map above it.
{ id: 'countrygap', label: 'The Gap by Country', file: 'country-gap' },
// GAPS & OPPORTUNITIES leads: it is the table that answers "where can the work
// actually happen", which is the whole point of the corpus.
{ id: 'gaps', label: 'Gaps & Opportunities', file: 'gaps-and-opportunities' },

View file

@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* build-country-gap-table.mjs — the country aggregate as an exportable table.
*
* WHY. gospel-by-country now carries 242 countries with real per-country counts, and the
* map uses them for the choropleth and the "where the gap concentrates" sheet. But the
* tables profile had no country table at all, so those numbers existed only inside the map
* — readable, not exportable. A mobiliser deciding where to send a team cannot work from a
* sheet they have to screenshot; the other seven tabs all offer CSV and JSON.
*
* It is DERIVED, not a second source. Every row is read out of gospel-by-country.json,
* which is itself refreshed from the pin index, so this cannot drift from the map: the
* coverage gate's clause C recomputes reach off the pins and would catch it if it did.
*
* WHAT THE COLUMNS SAY. Three different questions, because they disagree and each is
* somebody's: how many groups have nothing (send translators), how many PEOPLE those are
* (send broadcasters), and what SHARE of the country that is (Oceania is half unreached by
* group and tiny by population). The curated columns are carried through as-is and left
* null where this build does not know them — null, never zero, which would be a claim.
*/
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 SRC = resolve(REPO, 'data/tables/gospel-by-country.json')
const OUT = resolve(REPO, 'data/tables/country-gap.json')
const src = JSON.parse(readFileSync(SRC, 'utf8'))
const rows = (src.countries || [])
.filter((c) => c.groups > 0)
.map((c) => ({
country: c.country || c.iso3,
iso3: c.iso3,
region: c.region || '',
people_groups: c.groups,
groups_with_nothing: c.no_resource,
pct_with_nothing: c.pct_no_resource,
people_with_nothing: c.pop_no_resource,
groups_with_scripture: c.scripture,
groups_with_film: c.film,
band: c.band,
growing_unreached: c.growing_unreached === undefined ? null : c.growing_unreached,
deaths_day: c.deaths_day === undefined ? null : c.deaths_day,
basis: c.from_pins_only ? 'pins only — no curated country analysis for this country' : 'pins, with curated country analysis',
}))
.sort((a, b) => b.groups_with_nothing - a.groups_with_nothing || b.people_with_nothing - a.people_with_nothing)
const columns = [
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'ISO3', field: 'iso3', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'People groups', field: 'people_groups', type: 'number' },
{ title: 'Groups with nothing', field: 'groups_with_nothing', type: 'number' },
{ title: '% with nothing', field: 'pct_with_nothing', type: 'number' },
{ title: 'People with nothing', field: 'people_with_nothing', type: 'number' },
{ title: 'Groups with Scripture', field: 'groups_with_scripture', type: 'number' },
{ title: 'Groups with film', field: 'groups_with_film', type: 'number' },
{ title: 'Band', field: 'band', type: 'string' },
{ title: 'Growing & unreached', field: 'growing_unreached', type: 'number' },
{ title: 'Est. deaths/day', field: 'deaths_day', type: 'number' },
{ title: 'Basis', field: 'basis', type: 'string' },
]
const tot = rows.reduce((a, r) => a + r.groups_with_nothing, 0)
const pop = rows.reduce((a, r) => a + r.people_with_nothing, 0)
const byRegion = {}
for (const r of rows) {
const e = (byRegion[r.region || '—'] ||= { groups: 0, none: 0 })
e.groups += r.people_groups
e.none += r.groups_with_nothing
}
const regionLine = Object.entries(byRegion)
.map(([k, v]) => [k, v, v.groups ? (100 * v.none) / v.groups : 0])
.sort((a, b) => b[2] - a[2])
.map(([k, v, p]) => `${k} ${p.toFixed(1)}% (${v.none} of ${v.groups})`)
.join(' · ')
writeSnapshot(
OUT,
`${JSON.stringify({
columns,
meta: {
title: 'The gap by country',
grain: 'one row per country that has at least one people group on this map',
generated: new Date().toISOString().slice(0, 10),
measured: `${rows.length} countries · ${tot.toLocaleString()} groups with nothing found · ${pop.toLocaleString()} people`,
by_region: regionLine,
three_questions: 'Groups with nothing, people with nothing, and the share of the country each represents. They rank differently on purpose — Papua New Guinea has the most groups and few people, China the reverse, and Oceania has the worst share of any region while being the smallest by population.',
source: 'data/tables/gospel-by-country.json, which is itself refreshed from data/tables/people-group-pins.json — the same index the map draws, so this table and the map cannot disagree.',
curated_columns: 'growing_unreached and deaths_day come from the curated country analysis and are null where it does not cover the country. Null, never zero: zero is a claim this build cannot make.',
},
rows,
}, null, 1)}\n`,
(j) => (j.rows || []).length,
'country-gap rows',
)
console.log(`country-gap: ${rows.length} countries · ${tot.toLocaleString()} groups with nothing · ${pop.toLocaleString()} people`)
console.log(` by region: ${regionLine}`)

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG" 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

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG" 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

@ -591,7 +591,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "doxa-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk",
"integrity": "sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG",
"elements": [
"gospel-resources-map"
],
@ -611,7 +611,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -625,7 +625,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -639,7 +639,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -653,7 +653,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -667,7 +667,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-WZSlVBydYxeLPK42l4lhgBPjrSOBIIyw33bAuh5kD7c6uKgWMramyveBq5Z9G5Zk\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-CmN5mxV23d/9EjjiFYmjAp3wNs9izSW8X0nwgUPBk19XghuuGD0LnmsmmehdqVdG\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]