List what is missing, in a form you can take away

Every table in this bundle lists what EXISTS. The map can filter to the gap and rank its
40 largest, but 40 is a preview and a map is not something you hand to a prayer group or
work through in a spreadsheet. There was no exportable list of what is missing.

"Where Nothing Was Found" is that list: 3,457 people groups, 347,030,459 people, 183 of
them speaking a sign language. Largest first, with CSV and JSON export already provided by
the shared table.

IT IS NOT THE EXISTING "GAPS & OPPORTUNITIES" TABLE and could not have been folded into
it. That one is one row per group across the curated 2,106-record set, so it structurally
cannot see these 3,457. It keeps its own job — bridge-language analysis for the groups it
does cover — and this sits beside it.

EVERY ROW CARRIES ITS OWN PROVENANCE: checked_pools and unchecked_pools travel with the
data, so an exported CSV cannot be read as a finding about the world. Rows with an EMPTY
"pools checked" are groups whose language was in no resource table at all, and they say so
rather than looking identical to a group that was searched and came back empty. The meta
states outright that audio has never been searched for any group here.

THE COUNTRY CLICK NOW REACHES IT. The deep-link filter applied only to the resource table,
so a country click could show what a country HAS and never what it lacks — the half a
mobiliser came for. Chad: 94 of 3,457. Nepal: 54. Both match the map's own country sheet
exactly, which is the check that the two surfaces are describing the same groups.

180 ROWS PRINTED A RAW ISO3 IN THE COUNTRY COLUMN — "CHE", plus Canada, Taiwan,
Timor-Leste, Mexico, Argentina. gospel-by-country knows 170 countries and the boundaries
file 162, while the pins span 242. Names now come from people-groups-slim, the source the
pins were built from, so coverage matches by construction rather than by luck; the curated
spellings still win where they exist. 180 -> 1. The one left is Kosovo, whose ISO coding is
genuinely contested across datasets, and its row is named "Deaf of Kosovo" so no reader is
misled — one honest code beats starting a hand-written country table.

Verified: the tab renders 3,457 rows with all eight columns and both export buttons, the
country filter isolates Chad and Nepal correctly, and the coverage gate plus the table and
deep-link probes all pass.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 08:08:59 +00:00
parent b771fa622d
commit 29e4b153a4
14 changed files with 166 additions and 32 deletions

File diff suppressed because one or more lines are too long

View file

@ -69,6 +69,10 @@
{
"name": "gospel-resource-links",
"rows": 3550
},
{
"name": "gospel-gaps",
"rows": 3457
}
]
}

View file

@ -32,6 +32,12 @@ const TABS = [
// open it" — the question a mobiliser asks first, and the only table whose every row
// carries a URL you can click.
{ id: 'links', label: 'Every Resource + Link', file: 'gospel-resource-links' },
// The other half of the same question. Every other table here lists what EXISTS; this
// one lists the 3,457 groups with nothing found, which is the list a mobiliser can
// actually export and work through. It is NOT the "Gaps & Opportunities" table below:
// 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' },
// 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' },
@ -85,13 +91,22 @@ const rawTable = computed(() => cache.value[current.value?.file] || null)
// Only the links table carries iso639_3/country_codes, so a filter is a NO-OP elsewhere
// rather than an empty table — switching tabs under an active filter must not look broken.
// Both keyed tables filter. The links table carries country_codes (the ISO3 set a
// LANGUAGE is spoken in); the gaps table is one row per people-group-IN-COUNTRY and
// carries iso3 directly. Filtering only the links table meant a country click could show
// you what a country HAS and never what it lacks — which is the half a mobiliser came for.
const FILTERABLE = new Set(['links', 'nothing'])
const table = computed(() => {
const t = rawTable.value
if (!t || !hasFilter.value || current.value?.id !== 'links') return t
if (!t || !hasFilter.value || !FILTERABLE.has(current.value?.id)) return t
const rows = (t.rows || []).filter((r) => {
if (fIso.value && String(r.iso639_3 || '').toLowerCase() !== fIso.value) return false
if (fCountry.value && !String(r.country_codes || '').toUpperCase().split(' ').includes(fCountry.value)) return false
return true
if (!fCountry.value) return true
// links: a language spoken in many countries. gaps: one row, one country.
const set = r.country_codes != null
? String(r.country_codes).toUpperCase().split(' ')
: [String(r.iso3 || '').toUpperCase()]
return set.includes(fCountry.value)
})
return { ...t, rows }
})
@ -162,12 +177,12 @@ useShadowStyles(
</div>
<div v-if="hasFilter" class="gt-filter">
<span v-if="current.id === 'links'">
Showing <b>{{ fmt(filterCount) }}</b> of {{ fmt(totalCount) }} resources
<span v-if="FILTERABLE.has(current.id)">
Showing <b>{{ fmt(filterCount) }}</b> of {{ fmt(totalCount) }} {{ current.id === 'nothing' ? 'groups' : 'resources' }}
for <b>{{ fLabel || fIso || fCountry }}</b>
<template v-if="!filterCount"> — nothing found for this selection in the pools checked, which is not proof that nothing exists.</template>
</span>
<span v-else>Filtered to <b>{{ fLabel || fIso || fCountry }}</b> — this tab is not filtered; only “Every Resource + Link” carries a language key.</span>
<span v-else>Filtered to <b>{{ fLabel || fIso || fCountry }}</b> — this tab is not filtered; only the two keyed tables carry a language or country key.</span>
<button type="button" class="gt-clear" @click="clearFilter">Show all</button>
</div>

View file

@ -0,0 +1,108 @@
#!/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' : '',
checked_pools: c.ck || '',
unchecked_pools: c.un || '',
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.',
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: '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)`)

1
public/assets/tables/gospel-gaps.json vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -69,6 +69,10 @@
{
"name": "gospel-resource-links",
"rows": 3550
},
{
"name": "gospel-gaps",
"rows": 3457
}
]
}

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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d" 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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d" 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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d" 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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d" 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

@ -556,7 +556,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "doxa-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj",
"integrity": "sha384-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d",
"elements": [
"gospel-resources-map"
],
@ -572,7 +572,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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj\" 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-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d\" 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>"
}
},
{
@ -586,7 +586,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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj\" 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-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d\" 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>"
}
},
{
@ -600,7 +600,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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj\" 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-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d\" 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>"
}
},
{
@ -614,7 +614,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-ZCOtE4Phz/kfvSIV412QFIl5Xx9ceLGj+tXY9TmxspkK9U7oEFdRBkbsWN0KhVCj\" 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-vmbpOrfOvCRj8O6vTH6y1PLKbEoeQAAChM31vSF6RRLlYHLMt56NCb7TmKaAHF0d\" 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>"
}
}
]