Rank the gap, and name the languages that have nothing

The filter shows WHERE the gap is; it could not say which part of it is biggest. The
3,444 groups with nothing were reachable only by tapping dots until a large one turned
up, which is not how anyone chooses a field.

"See the 40 largest" now opens a ranked list in the sheet — the slot the country and
group views already use, so no new geometry and no new collision with the control rail.
Sorted by population, because choosing where to go is choosing how many people are
behind the decision. Tapping a row flies there and opens that group. Fly first, THEN
open: opening sets `selected`, which swaps the sheet away from the list, so the other
order loses the map movement behind a re-render.

THE LIST EXPOSED A REAL DEFECT. The largest gaps are exactly the groups with no entry
in any pool, so they had no record to carry a language name and the sheet rendered
"Language: (blank) cjy" for a 60-million-speaker group. Every language is now named
from the people-group dataset, creating the entry where no pool has one: 2,251 codes
are named-only — the language is known, nothing was found for it anywhere.

That makes "has a code entry" ~100% by construction, so it is relabelled in the build
output as naming and NOT coverage. Coverage is the line under it and still reads 8,935
of 12,379, 72.2%. A metric that reaches 100% because its meaning changed is worse than
no metric.

Verified on the served tree: 40 rows, populations strictly descending 60,310,000 ->
974,000 so "largest" is not a claim taken on trust; tapping row 1 moves zoom 1.3 -> 4.2
and opens Han Chinese Jinyu, whose scripture_url and film_url are empty and whose
Door43 list is empty — so the list is not mislabelling groups that have something. 200
sampled zero-resource groups all name their language, where the screenshot before this
change shows that field blank. Pins, table, deep-link and filter probes all still pass,
and the full a11y sweep passes 810 of 810 buttons across 21 leaf pages.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 04:30:11 +00:00
parent fc04f14a52
commit f68ffc33ba
15 changed files with 106 additions and 38 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -162,6 +162,33 @@ function setPinFilter(v) {
: ['==', ['get', 'tier'], 2])
}
// THE RANKED GAP. "Where is the gap" is answered by the filter; "which gap is biggest"
// is the question that actually picks a field, and nothing answered it — the 3,444 groups
// with nothing were reachable only by tapping dots until you found a large one.
// Sorted by population because a mobiliser choosing where to go is choosing how many
// people are behind the decision, not how many rows are.
const showGapList = ref(false)
const gapList = computed(() => {
if (!showGapList.value) return []
const out = []
for (let i = 0; i < pinRows.value.length; i++) {
const r = pinRows.value[i]
if (r[6] === 0) out.push({ i, name: r[0], lat: r[1], lng: r[2], iso3: r[3], code: r[4], pop: r[5] })
}
out.sort((a, b) => b.pop - a.pop)
return out.slice(0, 40)
})
function openGapList() { showGapList.value = true; selected.value = null; selectedGroup.value = null }
function closeGapList() { showGapList.value = false }
// Fly first, THEN open: openPin sets `selected`, which swaps the sheet away from this list,
// so doing it the other way round loses the map movement behind a re-render.
function gotoGap(g) {
const m = mapRef.value
if (m) m.flyTo({ center: [g.lng, g.lat], zoom: Math.max(m.getZoom(), 4.2), duration: 900 })
showGapList.value = false
openPin(g.i)
}
const pinTally = computed(() => {
const t = [0, 0, 0]
for (const r of pinRows.value) t[r[6]]++
@ -633,6 +660,9 @@ useShadowStyles(
Showing only <b>{{ pinFilter === 'none' ? 'groups with nothing found'
: pinFilter === 'some' ? 'groups with some resource' : 'groups with Scripture and film' }}</b>.
<button type="button" class="gm-kclear" @click="setPinFilter('all')">Show all {{ fmt(pinTally.total) }}</button>
<button v-if="pinFilter === 'none'" type="button" class="gm-kclear" @click="openGapList">
See the 40 largest →
</button>
</div>
</div>
<div class="gm-caveat">
@ -650,10 +680,26 @@ useShadowStyles(
<div v-if="err" class="gm-err">{{ err }}</div>
<!-- Tap a country: bottom sheet on mobile, corner card on desktop. -->
<div v-if="selected" class="gm-sheet">
<div v-if="selected || showGapList" class="gm-sheet">
<!-- ITEM 0 — the ranked gap: the largest groups with nothing found anywhere. -->
<template v-if="showGapList && !selected">
<div class="gm-sh-head">
<span class="gm-sh-name">Largest groups with nothing found</span>
<button type="button" class="gm-x" @click="closeGapList">Close</button>
</div>
<p class="gm-note" style="margin-top:0">
{{ fmt(pinTally.none) }} people groups have no Scripture, film or Door43 resource in the
pools checked. These are the 40 largest. Tap one to fly there and see what was checked.
</p>
<button v-for="g in gapList" :key="g.i" type="button" class="gm-pg gm-pg-btn" @click="gotoGap(g)">
<span class="gm-pg-n">{{ g.name }}</span>
<span class="gm-pg-l">{{ fmt(g.pop) }} people · {{ g.iso3 }}<code class="gm-code">{{ g.code || 'no code' }}</code></span>
</button>
</template>
<!-- ITEM 2 — one people group: what exists in THEIR OWN language. -->
<template v-if="selectedGroup">
<template v-else-if="selectedGroup">
<div class="gm-sh-head">
<button type="button" class="gm-back" @click="selectedGroup = null">‹ {{ selected.country }}</button>
<button type="button" class="gm-x" @click="closeSheet">Close</button>

View file

@ -133,6 +133,25 @@ for (const [c, ee] of Object.entries(eb)) {
ebOnly++
}
// NAME EVERY LANGUAGE, even one with no resource anywhere. A code only gets a `codes`
// entry when some pool carries something for it, so the groups with NOTHING — precisely
// the ones the gap list ranks — were rendering a blank language name next to their code.
// The slim dataset knows the name for every pin, so it is filled here and the entry is
// created when it does not exist. MEASURED: this is the sheet the gap list opens on, and
// "Language: (blank) cjy" for a 60-million-speaker group is the worst place to be vague.
let namedOnly = 0
for (const p of slim) {
const c = p.LanguageCode
const n = (p.LanguageName || '').trim()
if (!c || c.length !== 3 || !n) continue
if (!codes[c]) {
codes[c] = { l: n, ck: '', un: 'ebible, arclight, door43, grn, api_bible, dbl, progress_bible' }
namedOnly++
} else if (!codes[c].l) {
codes[c].l = n
}
}
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
const pins = []
let joined = 0, withUrl = 0, noCoord = 0, liftedByD43 = 0
@ -179,12 +198,15 @@ else idx.tables.push({ name: 'people-group-pins', rows: pins.length })
writeFileSync(INDEX, JSON.stringify(idx, null, 1))
const pct = (n) => `${((100 * n) / pins.length).toFixed(1)}%`
console.log(`pins ${pins.length}`)
console.log(`joined a record ${joined} (${pct(joined)})`)
// NOT a coverage number. Since every language is now named, this is ~100% by construction
// and means only "has a codes entry". The coverage number is the next line.
console.log(`has a code entry ${joined} (${pct(joined)}) <- naming, NOT coverage`)
console.log(`REAL URL ${withUrl} (${pct(withUrl)})`)
console.log(`language codes ${Object.keys(codes).length}`)
console.log(`door43 codes ${Object.keys(d43).length} (${d43Only} new)`)
console.log(`ebible codes ${Object.keys(eb).length} (${ebOnly} new)`)
console.log(`arclight codes ${Object.keys(arc).length} (${arcOnly} new)`)
console.log(`named-only ${namedOnly} (language named, no resource in any pool checked)`)
console.log(`lifted off 0 ${liftedByD43} (had nothing; now have a published Door43 resource)`)
console.log(`dropped nocoord ${noCoord}`)
console.log(`wrote ${OUT} (${(readFileSync(OUT).length / 1024).toFixed(0)} KB)`)

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

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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK" 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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK" 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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK" 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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK" 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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht",
"integrity": "sha384-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK",
"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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht\" 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-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK\" 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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht\" 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-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK\" 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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht\" 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-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK\" 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-DpU1SWn4u94XMNElOTNoASob58FSPB7BTxE522CQEVO+cyFgnwXgNoD6O55tfkht\" 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-Toqa+/OszJnojk7ji5Ca9G5mrAIEY8DPZWyhyeNwPthz8QmcO5eiqpFyFh7HWQbK\" 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>"
}
}
]