doxa-maps/plugin-designs/gospel-resources/resources-map.vue
doxa-maps-88655c70 57fecfd0da A gate that asks whether anything is there — and it caught a dead bundle
Wrote this after shipping a page that returned 200 for every asset, reported
zero console errors, and was completely dead: a throw during setup dies inside
the custom-element boundary. Zero errors is not the same as working, so this
does not trust their absence — it looks for PRESENCE, on all five leaves, in
five steps that each name their own failure: the tag is defined, the host is in
the document, a shadowRoot is attached, it has children, it renders text.

Its first real run went red on all five, and it was right. Every gospel leaf was
dead with "Identifier 'Ge' has already been declared" — while the SRI hash
still matched and every request still returned 200. My own doing: four profiles
imported en/gospel.json directly for an English fallback, and library/i18n
already pulls that exact file in through an eager glob, so the IIFE carried two
instances of one module.

The fix is one module: library/i18n now exports EN_MESSAGES and the profiles
read it from there. The duplicate is gone.

Measured after: all five leaves mount with 3,228 to 233,307 characters of
rendered text, verify-gospel-map passes with 2,107 pins and the gap cutting to
370, and --poison confirms the check goes red on a page with no element at all.

The lesson is the gate's whole reason to exist. I had rich assertions about pin
counts and filters and none of them ran on data-tables, charts or the list, so a
bundle-level failure could take out every leaf while the one gate I did run was
pointed elsewhere. Cheap and universal beats rich and narrow for THIS question.
2026-09-25 09:52:25 +00:00

2004 lines
121 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* resources-map.vue — THE GOSPEL RESOURCES GLOBAL MAP.
*
* @description 12,379 people-group pins over a country choropleth: tap a pin for the Scripture, audio, film, Bible-story and training links that exist in that group's own language with real URLs, tap a country for every group in it, search by group, language or ISO 639-3 code, and filter to the groups where nothing was found.
*
* 12,379 people-group pins over a country choropleth, coloured by what each group has
* in its OWN language: Scripture and film, something, or nothing found. Tap a pin for
* the real links; tap a country for every group in it. Search reaches a named group,
* the key doubles as a filter so the gap can be looked at on its own, and a shared URL
* reopens the group you were looking at.
*
* The country shading is a COUNT of Doxa's groups with nothing found — never a share,
* because 106 of their 175 countries hold one or two groups and a percentage over that
* denominator is noise. The dots are individual groups, which is why a dark dot can sit
* on a light country — that case is the point of the map, not a rendering fault. A
* country Doxa does not work is left uncoloured, not drawn as covered.
*
* > design note 2026-08-01: "mapping out the gospel resources is super important for me
* > … key city posters, cities with internet, gospel resources — all these are
* > missing maps."
*
* MOBILE FIRST, deliberately. The brief asked for mobile-friendliness before this
* map existed, so it is built that way rather than retrofitted:
* - one WebGL context (the ~16-cap rule; this is the app's only map pane)
* - tap-to-select with a bottom SHEET, not a hover popup — hover does not exist
* on a phone, and a hover-only map is an unusable map
* - the legend is a BOUNDED panel (380px, left-anchored), clear of the control
* rail on the right. It was a full-width strip; that is what made it collide.
* - boundaries FETCHED at runtime (the IIFE inlines every import; 2.7MB of
* geojson in the bundle would block first paint)
*
* ═══ WHAT THE COLOUR MEANS, AND WHAT IT MUST NOT BE READ AS ═════════════════
* The band is the share of a country's people groups at TIER 0 — no resource of
* ANY kind found in any pool checked. It is recomputed from the pin index, so the
* colour and the dots cannot disagree.
*
* This comment was wrong in three ways and is worth correcting precisely, because
* it is what the next reader trusts: the band is not "no scripture and no film",
* it is no resource at all; the pools are not "eBible + Arclight" but eBible,
* PNG.Bible, Arclight, Door43, Faith Comes By Hearing, Scripture Earth, Cru and
* Global Recordings Network; and GRN is NOT key-blocked — it is reached through
* their 5fish site, needs no key, and has now been swept in full at the crawl
* delay their robots.txt asks for. API.Bible, DBL and progress.bible are the ones
* that still need credentials this build does not have.
*
* So a red country is "we found nothing for most of these groups", NOT "nothing
* exists". That distinction is printed in the legend and repeated in every detail
* sheet, because a map is exactly where a caveat gets lost.
*
* SAFETY: this shades RESOURCE AVAILABILITY per language, aggregated to country.
* It carries no church, believer or meeting-point data and must never gain any.
*
* ═══ TWO MODES, BECAUSE ONE SHADING CANNOT ANSWER BOTH QUESTIONS ════════════════
* GAP (default) shades what is MISSING; COVERAGE shades what was FOUND. They are
* not redundant — a country can be mostly-covered by group count while the groups
* still lacking anything are the large ones, and only flipping the shading shows it.
* GAP leads because it is the question the corpus was built to answer: where is
* there no resource to point someone to. Neither mode is "all the resources"
* though — a choropleth is an average and cannot list anything. The actual
* resources live in the country sheet below, which lists every group, its language,
* its ISO 639-3 code and its links, with the link-verification state on each.
*
* ═══ JOINING A CLICKED COUNTRY TO ITS PEOPLE GROUPS ═════════════════════════════
* DO NOT `split(',')` the `countries` field. Country names in this corpus CONTAIN
* commas — "Micronesia, Federated States of", "Virgin Islands, British" — so a
* naive split shatters them into fragments ("Micronesia" + "Federated States of")
* and the row silently lands under a country that does not exist. Instead the names
* are matched longest-first against the known country vocabulary with comma
* boundaries. Verified against the corpus's own `country_count`: 0 of 2,106 rows
* disagree, and the 5-country string above parses to exactly its 5 real names.
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
// Map toolbar — canonical modular control rail (MapToolbar slot-wrapper + button
// children). ThemeToggleButton is omitted: this profile's isDark is a read-only
// computed off profile-config with no theme-toggle handler to wire it to.
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import LocationButton from '@map/components/map-controls/LocationButton.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
const profileConfig = inject('profileConfig', ref(null))
const isDark = computed(() => profileConfig.value?.isDark === true)
const mapboxToken = computed(() => profileConfig.value?.tk || window.DOXA_TOKEN || '')
// ── HONOUR THE PROVIDER SWITCH: this profile builds its map RAW ─────────────
// Bundles that go through useMapInstance get the switch for free; this one calls `new gl.Map`
// directly, so it never saw it. MEASURED by sweeping all 16 isMap leaves: at ?provider=maplibre
// this leaf still loaded Mapbox and made 18 requests to api.mapbox.com, identical to its mapbox
// run. Same shape and same fix as cities-internet/connectivity-map and my-upg-100-list; the engine
// loader is IMPORTED from useMapInstance, never a second copy of the ENGINES table.
// No geocoder is mounted here, so the deferred-search-bar rule does not apply to this profile.
const grProvider = resolveMapProvider(profileConfig)
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
const mapEl = ref(null)
const mapRef = shallowRef(null)
const rows = ref([])
const selected = ref(null)
const err = ref(null)
// GAP = what is missing (default). COVERAGE = what was found. See the header note.
const mode = ref('gap')
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
/**
* ═══ THE COLOUR SCALE, AND WHY IT COUNTS INSTEAD OF DIVIDING ════════════════════════════
*
* The old ramp painted a country by the SHARE of its groups with nothing found. Measured
* against Doxa's roster that is not a statistic, it is noise: 106 of their 175 countries
* hold one or two groups, and the United States holds three — Coquille, Pequots, Umpqua.
* One of three without a resource is 33%, so the whole United States rendered amber next
* to India, which holds 855. The map read as "most of the world is in crisis" because of
* arithmetic, not because of the world. Swapping in a prettier ramp would have kept the
* lie and made it handsomer.
*
* So the scale is a COUNT: how many of Doxa's groups in this country have nothing found.
* A count cannot be inflated by a small denominator, it is what a team would actually plan
* around, and it puts India, Sudan and Laos where they belong.
*
* OUT OF SCOPE IS NOT ZERO. A country with no Doxa groups gets NO FILL at all. Painting it
* "green/covered" claimed a finding about a place this dataset says nothing about, which is
* the same class of error as drawing an unmeasured figure as a zero.
*
* THE RAMP. Single family, light to dark, on the house light palette — sage for "every Doxa
* group here has something", then four steps of clay deepening with the count. No yellow:
* a mid-spectrum yellow over a pale basemap is what made the old map look jaundiced, and it
* also carries the least luminance contrast of any hue against #f3f3f1. Sage and clay sit
* far apart in hue AND in lightness, so the scale survives being read without colour.
*/
const NO_DOXA_FILL = 'rgba(0,0,0,0)'
const BANDS = [
{ id: 'd0', labelKey: 'gospel.legend.doxa0', color: '#92b195', min: 0, max: 0 },
{ id: 'd1', labelKey: 'gospel.legend.doxa1', color: '#e8d9cd', min: 1, max: 4 },
{ id: 'd5', labelKey: 'gospel.legend.doxa5', color: '#cfa388', min: 5, max: 14 },
{ id: 'd15', labelKey: 'gospel.legend.doxa15', color: '#b06f57', min: 15, max: 39 },
{ id: 'd40', labelKey: 'gospel.legend.doxa40', color: '#7d3b30', min: 40, max: Infinity },
]
/* Coverage mode asks the opposite question over the same roster: how many Doxa groups here
DO have something. Same count logic, same reason, mirrored ramp. */
const COVER_BANDS = [
{ id: 'c1', labelKey: 'gospel.legend.cvd1', color: '#e8d9cd', min: 1, max: 4 },
{ id: 'c5', labelKey: 'gospel.legend.cvd5', color: '#a8c4a9', min: 5, max: 14 },
{ id: 'c15', labelKey: 'gospel.legend.cvd15', color: '#6f9673', min: 15, max: 39 },
{ id: 'c40', labelKey: 'gospel.legend.cvd40', color: '#3b463d', min: 40, max: Infinity },
]
const bandFor = (list, n) => list.find((b) => n >= b.min && n <= b.max) || null
/** A country's fill. Null tally = Doxa does not work here = no fill, never a colour. */
const featureColor = (iso3) => {
const t = doxaByCountry.value[iso3]
if (!t) return NO_DOXA_FILL
const n = mode.value === 'gap' ? t.none : t.groups - t.none
const b = bandFor(mode.value === 'gap' ? BANDS : COVER_BANDS, n)
return b ? b.color : NO_DOXA_FILL
}
const bandColor = (b) => (BANDS.find((x) => x.id === b) || {}).color || NO_DOXA_FILL
const activeBands = computed(() => (mode.value === 'gap' ? BANDS : COVER_BANDS))
// PIN TIERS ARE THEIR OWN SCALE NOW, and deliberately so. They used to index into BANDS,
// which was safe while both scales measured the same thing. They no longer do: a band is a
// COUNT of Doxa groups in a country, a pin is what ONE group HAS. Indexing a count ramp by
// a tier would have painted "nothing found" in the colour of "1-4 groups" — two different
// facts wearing one colour, which is the disagreement that comment was written to prevent.
// Dark green reads as complete, sage as partial, oxblood as empty; all three sit apart from
// the clay country ramp in hue and in lightness, so a pin never dissolves into its country.
const PIN_COLOR = {
2: '#3b463d', // Scripture AND film in their own language
1: '#92b195', // one of the two
0: '#7d3b30', // nothing found in the pools checked — the pin that matters most
}
const byIso = computed(() => {
const m = {}
for (const r of rows.value) m[r.iso3] = r
return m
})
/* ══ PEOPLE-GROUP DETAIL — fetched lazily, on the first country tap ═════════════
* people-group-resources.json is 1.4MB. Loading it up-front to populate a sheet
* nobody has opened yet would delay the map's first paint for every visitor. */
const prayIndex = ref({})
const pgReady = ref(false) // link-verification fetched (the group list comes from pins)
const linkVerify = ref(null)
const pgLoading = ref(false)
const pgErr = ref(null)
const selectedGroup = ref(null) // set = the people-group detail is showing
const pinRows = shallowRef([]) // [name, lat, lng, iso3, code, population, tier]
const doxaTotal = ref(0) // every group on Doxa's roster, placed or not
const doxaUnplaced = ref(0) // on the roster, no coordinates in this build yet
const doxaByCountry = shallowRef({})
const pinCodes = ref({})
const pinBridges = ref({}) // '<iso639-3>|<ISO3 country>' -> the in-country bridge, if any
/* Declared beside pinBridges. The previous edit USED this ref without declaring it —
a silent string replace that matched nothing — and the ReferenceError died inside the
custom-element boundary, so the map simply never mounted and the page reported no error
at all. Zero errors is not the same as working. */
const pinCandidates = shallowRef({})
const pinCount = ref(0)
// The legend quotes these, so they can never drift from what the layer is drawing.
// PIN FILTER. The map answers "what does this group have" on tap, but it could not answer
// "show me only the ones with nothing" — which is the question the map exists for. Filtering
// the LAYER rather than rebuilding the source: setFilter is a GL-side predicate, so 12,379
// features stay on the GPU and nothing is re-parsed or re-uploaded on a toggle.
// HEARD, NOT READ. The triage a translation team actually runs: a group that can already
// hear the gospel — recorded audio, the JESUS Film, a Bible video, Words of Life — but has
// no TEXT Scripture found in their language. It is the largest actionable set on this map
// and nothing could select it; the tier filter only separates nothing / something / both.
//
// "No text found" is a statement about the pools checked, never about the world, and the
// button says so. Several of the largest entries here — Western Punjabi, Shanghainese,
// Egyptian Arabic — are varieties where a recording exists and a text in THAT variety is
// genuinely contested, which is exactly why the claim has to stay bounded.
/**
* Was a pool ACTUALLY SEARCHED for this code? The absent-case chips depend on it.
*
* The GRN sweep ran against the GAP first — the 1,192 codes with nothing else — and stopped
* there, so 3,202 codes that already carry a resource have never been asked. The build
* records that per code: a swept code lists `grn` in checked_pools, an unswept one carries
* "grn (not yet swept)" in unchecked_pools.
*
* A first version of the GRN chip ignored this and told every group "none found at GRN".
* On German — which certainly does have Words of Life — that was simply false, and it was
* false in the same way the legend caveat had been an hour earlier: stating a result for a
* search nobody ran. The data was already right; only the chip was wrong. So the chip asks
* the data rather than assuming the sweep was universal.
*/
/**
* Does this country actually HAVE the curated analysis? 72 of 242 do not, and the two †
* figures were rendering for them regardless — deaths/day fell through `|| 0` and drew a
* zero. A measurement nobody made must not appear as a measurement of zero.
*/
const hasCurated = computed(
() => selected.value != null &&
(selected.value.growing_unreached != null || selected.value.deaths_day != null),
)
const poolSearched = (g, pool) =>
new RegExp(`(^|,\\s*)${pool}(\\s*,|$)`).test(String(g?.checked_pools || ''))
const hasTextScripture = (c) =>
!!c.s ||
!!(c.au && /text/.test(c.au.m || '')) ||
!!c.se ||
(c.d || []).some((d) => !/^Open Bible Stories/.test(d.s || ''))
// NEVER a sign language. "Can hear the gospel" is the wrong sentence about a Deaf people
// group, and recorded audio is not the thing they lack or the thing they need — the map
// already suppresses the audio chip and the GRN attach for them for the same reason. This
// filter shipped without the guard and counted 49 sign languages among the 2,289.
const canHear = (c) =>
c.sg !== 1 &&
(!!c.ga || !!(c.au && /audio/.test(c.au.m || '')) || !!c.f || !!c.bv)
const pinFilter = ref('all') // all | none | some | both | hnr
function setPinFilter(v) {
pinFilter.value = v
const m = mapRef.value
if (!m || !m.getLayer('gr-pins')) return
m.setFilter('gr-pins',
v === 'all' ? null
: v === 'none' ? ['==', ['get', 'tier'], 0]
: v === 'some' ? ['==', ['get', 'tier'], 1]
: v === 'hnr' ? ['==', ['get', 'hnr'], true]
: ['==', ['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.
// SEARCH. 12,379 dots and no way to reach a named one: finding "Fulani" or "Uzbek" meant
// hunting the map by eye. Matches a people-group name, its language, or its ISO 639-3
// code, because a user arrives with whichever of the three they happen to know.
// SUBSTRING, NOT FUZZY: a fuzzy match over 12,379 rows returns plausible WRONG groups,
// and handing someone the resources for a similarly-named people is the failure this map
// is most careful about everywhere else.
// SHARE A GROUP, NOT JUST THE MAP. Search made a group findable; nothing made it
// sendable — a colleague could be pointed at the map but not at the Fulani group you
// just found. The share URL now carries the selection.
//
// KEYED ON code+country+name, NEVER on the pin INDEX. The index shifts every time the
// pin table is rebuilt, so an index-based link would silently open a DIFFERENT people
// group after the next data refresh — a link that rots into a wrong answer is worse than
// one that breaks. These three are stable identifiers and are matched exactly.
//
// The query rides on embedPath rather than teaching ShareButton about parameters:
// that component has other consumers and none of them need this.
const BASE_EMBED = 'gospel-resources/resources-map/index.html'
const shareEmbedPath = computed(() => {
const g = selectedGroup.value
if (!g || !g.iso639_3) return BASE_EMBED
const p = new URLSearchParams({ code: g.iso639_3, country: g.countries || '', name: g.people_group || '' })
return `${BASE_EMBED}?${p.toString()}`
})
// Reopen what a shared link names. Runs once the pins are in hand, because resolving a
// group needs the table the link refers to.
function openFromUrl() {
let sp
try { sp = new URLSearchParams(window.location.search) } catch { return }
const code = (sp.get('code') || '').trim()
if (!code) return
const country = (sp.get('country') || '').trim()
const name = (sp.get('name') || '').trim()
let best = -1
for (let i = 0; i < pinRows.value.length; i++) {
const r = pinRows.value[i]
if (r[4] !== code) continue
// exact name+country wins; code+country next; code alone last.
if (name && r[0] === name && (!country || byIso.value[r[3]]?.country === country || r[3] === country)) { best = i; break }
if (best < 0) best = i
}
if (best < 0) return
const r = pinRows.value[best]
const map = mapRef.value
if (map) map.flyTo({ center: [r[2], r[1]], zoom: Math.max(map.getZoom(), 4.2), duration: 0 })
openPin(best)
}
// LEGEND COLLAPSE, NARROW SCREENS ONLY. MEASURED at 390x844: the legend rendered 469px
// tall over a 675px map — 69% of the map hidden behind its own explanation, and the search
// box added this flight made it worse. Everything in it earns its place on a desktop, where
// it is 290px of a 900px map; on a phone the prose has to fold.
// Search and the Gap/Resources toggle stay visible, because they are the two controls a
// reader touches. The key and the caveats fold behind one button — folded, not deleted:
// "a red dot can sit on a green country" is the thing that stops a correct map reading as
// a bug, so it stays one tap away rather than being dropped on small screens.
const narrow = ref(false)
const legendOpen = ref(true)
function syncNarrow() {
const n = typeof window !== 'undefined' && window.innerWidth < 700
if (n === narrow.value) return
narrow.value = n
legendOpen.value = !n
}
if (typeof window !== 'undefined') {
syncNarrow()
window.addEventListener('resize', syncNarrow)
}
const q = ref('')
const matches = computed(() => {
const needle = q.value.trim().toLowerCase()
if (needle.length < 2) return []
const out = []
for (let i = 0; i < pinRows.value.length && out.length < 200; i++) {
const r = pinRows.value[i]
const lang = pinCodes.value[r[4]]?.l || ''
if (r[0].toLowerCase().includes(needle) ||
lang.toLowerCase().includes(needle) ||
r[4].toLowerCase() === needle) {
out.push({ i, name: r[0], lang, iso3: r[3], code: r[4], pop: r[5], tier: r[6] })
}
}
// Largest first: a name like "Fulani" matches dozens of groups and the biggest is the
// one most likely meant.
return out.sort((x, y) => y.pop - x.pop)
})
function gotoMatch(m) {
const r = pinRows.value[m.i]
const map = mapRef.value
if (map && r) map.flyTo({ center: [r[2], r[1]], zoom: Math.max(map.getZoom(), 4.2), duration: 900 })
q.value = ''
showGapList.value = false
openPin(m.i)
}
const showGapList = ref(false)
const gapPeople = computed(() => {
let n = 0
for (const r of pinRows.value) if (r[6] === 0) n += r[5] || 0
return n
})
const gapList = computed(() => {
if (!showGapList.value) return []
const out = []
for (let i = 0; i < pinRows.value.length; i++) {
const r = pinRows.value[i]
// `rel` is carried into the row because this list is the thing that gets read
// top-down, and its top two entries are exactly the varieties that need the caveat.
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], rel: pinCodes.value[r[4]]?.rel || null })
}
out.sort((a, b) => b.pop - a.pop)
return out.slice(0, 40)
})
// WHERE, not just WHO. The ranked group list answers "which single group is largest";
// a mobiliser also asks "where would a team go", and the answer concentrates hard: the
// top 10 countries hold 56.6% of all 2,901 groups with nothing.
// Both axes are shown because they disagree, and the disagreement is the useful part —
// Papua New Guinea has 469 groups with nothing but only 2.2M people, China has 125 groups
// and 150.3M. Ranking by either one alone hides the other question.
const gapBy = ref('groups')
// THE SHARPEST INTERSECTION THIS DATA HOLDS: no gospel resource found in any pool checked,
// AND nobody praying. 213 people groups, 4.67M people. Every other view here ends at "this
// group has nothing"; this one ends at a page where the reader can do something about it,
// which is why the rows are links rather than fly-to buttons.
//
// "NOBODY PRAYING" MEANS ZERO ON DOXA.LIFE'S COUNTER. It is not a claim that nobody in the
// world prays for these people — an unknowable thing — and the sheet says so rather than
// letting the number imply it.
// THE SAME SET THE MAP FILTER SELECTS, RANKED. The filter answers "who"; a list answers
// "which of them first", and 2,289 dots cannot be read top-down. Same treatment as the
// nothing-found list: largest population first, because that is what a team choosing a
// field is actually weighing. What each group DOES have is carried onto the row, so the
// reader can see whether they are being sent to a recording or a film before they go.
// WHAT THE GAP NUMBER IS MADE OF. "1,913 groups have nothing" is four different
// situations wearing one number, and they call for four different responses:
// - a private-use code (qaa-qtz) or no code at all: NO pool can index them, so this is a
// cataloguing problem and no amount of searching will move it;
// - a sign language: the pools that would hold signed Scripture are key-gated, so this is
// an access problem — 136 groups and fifty million people sit here;
// - a code GRN has not reached yet: this bucket is now EMPTY — the sweep finished — but the
// branch stays, because the next pool swept a batch at a time will need it again;
// - everything else: every pool this build can reach was checked and found nothing. That
// last line is the real translation gap, and it is the only one of the four that a
// translator rather than an administrator can close.
// Computed from the pins and the per-code `checked` line, never from a typed number.
// How many of the four situations actually have rows today. The sweep empties one of them
// on completion, and a sentence reading "Four different situations" beside a line saying
// "0 groups — GRN has not been swept for them yet" would be two lies at once: the count and
// the implication that some remain.
const gapKindsPresent = computed(() => {
const m = gapMakeup.value
return [m.searched, m.pending, m.sign, m.priv + m.noCode].filter((n) => n > 0).length
})
const WORDS = ['no', 'One', 'Two', 'Three', 'Four']
const gapKindsWord = computed(() => WORDS[gapKindsPresent.value] || String(gapKindsPresent.value))
const gapMakeup = computed(() => {
const m = { noCode: 0, priv: 0, signPriv: 0, sign: 0, pending: 0, searched: 0,
noCodeP: 0, privP: 0, signPrivP: 0, signP: 0, pendingP: 0, searchedP: 0 }
for (const r of pinRows.value) {
if (r[6] !== 0) continue
const code = r[4]
const c = pinCodes.value[code] || {}
const pop = r[5] || 0
if (!code) { m.noCode++; m.noCodeP += pop; continue }
// SIGN LANGUAGE IS TESTED FIRST, and the order is the whole point. Every private-use
// code in this gap is also a sign language, so testing private-use first split one
// population across two lines and understated the Deaf share by 47 groups. Being Deaf
// is the more actionable fact about these rows than the shape of their code, and the
// exported gaps table classifies them the same way — the two must not disagree.
if (c.sg === 1) {
m.sign++; m.signP += pop
if (/^q[a-t][a-z]$/.test(code)) { m.signPriv++; m.signPrivP += pop }
continue
}
if (/^q[a-t][a-z]$/.test(code)) { m.priv++; m.privP += pop; continue }
if (!/\bgrn\b/.test(c.ck || '')) { m.pending++; m.pendingP += pop; continue }
m.searched++; m.searchedP += pop
}
return m
})
const gapHnr = 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) continue
const c = pinCodes.value[r[4]]
if (!c || !canHear(c) || hasTextScripture(c)) continue
const has = []
if (c.ga) has.push('gospel audio')
if (c.au && /audio/.test(c.au.m || '')) has.push('audio Scripture')
if (c.f) has.push('the JESUS Film')
if (c.bv) has.push('Bible video')
out.push({ i, name: r[0], lat: r[1], lng: r[2], iso3: r[3], code: r[4], pop: r[5], has: has.join(', ') })
}
return out.sort((a, b) => b.pop - a.pop).slice(0, 40)
})
const gapNoPrayer = 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) continue
const e = prayIndex.value[prayKey(r[0], r[3])]
if (!e || e.praying > 0) continue
out.push({ i, name: r[0], iso3: r[3], code: r[4], pop: r[5], url: e.u })
}
return out.sort((a, b) => b.pop - a.pop).slice(0, 40)
})
// A THIRD AXIS: the RATE. The country tab ranks by how many groups a country holds and
// shows population beside it; neither answers "what share of this place is unreached".
// Measured, the three disagree completely and each is somebody's question:
// Oceania 51.2% of its groups have nothing — the worst rate anywhere — but 3.1M people
// Asia 19.3%, and 198.7M people with nothing — by far the largest population
// Africa 30.0%, 923 groups — the most after Asia
// Ranked by rate here, because count and population are already answered by the other two
// tabs and a third copy of either would be noise.
const gapRegions = computed(() => {
if (!showGapList.value) return []
const by = {}
for (const r of pinRows.value) {
const reg = byIso.value[r[3]]?.region || ''
if (!reg) continue
const e = (by[reg] = by[reg] || { region: reg, groups: 0, none: 0, pop: 0 })
e.groups++
if (r[6] === 0) { e.none++; e.pop += r[5] || 0 }
}
return Object.values(by)
.map((e) => ({ ...e, pct: e.groups ? Math.round((1000 * e.none) / e.groups) / 10 : 0 }))
.sort((a, b) => b.pct - a.pct)
})
const gapCountries = computed(() => {
if (!showGapList.value) return []
const by = {}
for (const r of pinRows.value) {
if (r[6] !== 0) continue
const e = (by[r[3]] = by[r[3]] || { iso3: r[3], groups: 0, pop: 0 })
e.groups++
e.pop += r[5] || 0
}
return Object.values(by)
.map((e) => ({ ...e, country: byIso.value[e.iso3]?.country || e.iso3 }))
.sort((a, b) => b.groups - a.groups)
.slice(0, 40)
})
function openGapList() {
// The prayer index is fetched lazily and the "Nobody praying" tab is built from it, so
// opening this list without having first clicked a pin left the tab hidden entirely —
// the view existed and was unreachable unless you happened to take the long way round.
ensurePeopleGroups()
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)
}
// Counted, not typed. The same rule as the legend's tallies: a number written into copy
// is wrong the first time the data moves, and this one sits inside a claim about a group
// of people.
const signTally = computed(() => {
let codesSeen = new Set(), people = 0, both = 0
for (const r of pinRows.value) {
if (pinCodes.value[r[4]]?.sg !== 1) continue
codesSeen.add(r[4]); people += r[5] || 0
if (r[6] === 2) both++
}
return { langs: codesSeen.size, people, both }
})
const pinTally = computed(() => {
const t = [0, 0, 0]
let hnr = 0
for (const r of pinRows.value) {
t[r[6]]++
const c = pinCodes.value[r[4]]
if (r[6] !== 0 && c && canHear(c) && !hasTextScripture(c)) hnr++
}
return { none: t[0], partial: t[1], both: t[2], hnr, linked: t[1] + t[2], total: pinRows.value.length }
})
async function ensurePeopleGroups() {
if (pgReady.value || pgLoading.value) return
pgLoading.value = true; pgErr.value = null
try {
// people-group-resources.json is NO LONGER fetched: the country list is built from
// the pins, which are already in memory and carry richer resource data than that
// 1.4MB file. Only the link verification remains, and it is allowed to fail —
// without it every link falls back to "unchecked", which is the honest state rather
// than a broken UI.
// Prayer links, keyed by (group name, country) rather than by language — see
// scripts/fetch-pray-index.mjs for why this one join is not by ISO code.
const pr = await fetch(`${assetsBase.value}/tables/pray-index.json`)
.then((r) => (r.ok ? r.json() : null)).catch(() => null)
prayIndex.value = pr?.byKey || {}
const lv = await fetch(`${assetsBase.value}/tables/gospel-link-verification.json`)
.then((r) => (r.ok ? r.json() : null)).catch(() => null)
linkVerify.value = lv?.links || {}
pgReady.value = true
} catch (e) { pgErr.value = String(e.message || e) }
finally { pgLoading.value = false }
}
// Longest-first so "Micronesia, Federated States of" is consumed before a bare
// "Micronesia" could ever match part of it. See the header note.
// ── THE COUNTRY LIST IS BUILT FROM THE PINS ──────────────────────────────────────
// It used to be built from people-group-resources.json, a curated 2,106-record subset,
// while the MAP draws 12,379 pins. The two described different universes and the sheet
// silently undercounted: MEASURED before this change — Indonesia 36 rows against 669
// pins, Nigeria 38 against 433, India 899 against 2,146. Tapping a country and being
// told it has 36 people groups while 669 dots sit under your finger is the map
// contradicting itself.
//
// Matching was also done by PARSING a comma-separated country-name string against a
// longest-first vocabulary, which is a second way to lose rows — a name that does not
// match the vocabulary exactly drops out silently. Pins carry ISO3, so the index is an
// exact key rather than a text search.
//
// This also retires the 1.4MB people-group-resources.json fetch: the pins are already in
// memory and now carry richer resource data than that file ever did.
const pinsByIso = computed(() => {
const m = {}
for (let i = 0; i < pinRows.value.length; i++) {
const r = pinRows.value[i]
;(m[r[3]] ||= []).push(i)
}
return m
})
// One shape for the sheet, whether a group arrived by pin tap, country list or
// same-language list. Divergent shapes here are how a field renders blank in one path
// and fine in another.
// Same normalisation the index was built with. Kept beside groupAt so the two cannot
// drift: a key built differently here would silently match nothing.
// Their directory names are file-format shorthand; a worker reading the sheet should see
// what they can actually open, not the folder name.
/**
* SHORT LABELS ARE TRANSLATED; THE EXPLANATORY PROSE IS NOT, AND THAT IS A DECISION.
*
* Every string on this surface used to be hard-coded English, so the map rendered identical
* English under ?lang=ar, zh, ru and hi — measured, 3,018 words either way and not one
* non-Latin character. The chrome a reader navigates by is now translated in all eleven
* locales from library/i18n/locales/<lc>/gospel.json.
*
* The prose is deliberately left in English and marked dir="ltr" where it sits. It carries
* doctrinal and statistical precision — which pools were searched, what "nothing found" does
* and does not claim, why Open Bible Stories is not Scripture — and a machine-translated
* caveat would read as authoritative while being wrong. gospel.prose.english is the one
* translated line that tells the reader, in their own language, that the notes are English.
* It stays extractable: when a human-reviewed translation exists it gets its own area file
* and that line comes out.
*
* i18n IS OPTIONAL. useI18n() throws when the host never called app.use(i18n), and this
* bundle is embeddable in pages that have not — the same failure that blanked a production
* plugin from an unguarded call in FilterDropdown. English is the fallback, never a blank.
*/
/* The fallback resolves against the SHIPPED ENGLISH CATALOG (imported at the top), not the
key. Returning the last dot-segment would have put "gap" and "deathsDay" on screen — a
broken-looking label is not graceful degradation, it just moves the bug. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let t = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
t = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
/* Whether to show the "these notes are in English" line at all. Read from the same i18n
instance the labels come from, so the two can never disagree. */
const isEnglish = (() => {
try { return String(useI18n().locale.value || 'en').split('-')[0] === 'en' } catch { return true }
})()
const SE_KIND = { PDF: 'PDF', audio: 'audio', sab: 'app', study: 'study notes', viewer: 'read online' }
const seKinds = (k) => (Array.isArray(k) ? k : []).map((x) => SE_KIND[x] || x).join(', ')
const prayKey = (name, iso3) =>
`${String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()}|${String(iso3 || '').toUpperCase()}`
// COMPUTED, not baked into groupAt. The index is fetched lazily, and groupAt runs the
// moment a pin is clicked — so a value read there is whatever had arrived by then, and
// never updates when the fetch lands. Measured: the control did not render at all, because
// the first click always beats the request. The same shape of bug as the link-verification
// state that hung off openPin's country branch.
const selectedPray = computed(() => {
const g = selectedGroup.value
if (!g) return null
return prayIndex.value[prayKey(g.people_group, g._iso3 || '')] || null
})
function groupAt(i) {
const r = pinRows.value[i]
if (!r) return null
const c = pinCodes.value[r[4]] || {}
return {
_i: i,
// The tier the pin index computed, carried so no consumer re-derives "has anything".
_tier: r[6],
people_group: r[0],
language: c.l || '',
iso639_3: r[4] || '',
population: r[5],
countries: byIso.value[r[3]]?.country || r[3],
_iso3: r[3] || '',
scripture_url: c.s || '',
film_url: c.f || '',
/* Empty except where the JESUS component does not carry this language and another
JESUS Film Project feature does. See gospel-resource-rows.js — the chip must say
which film it is, not which film we usually mean. */
film_title: c.fT || '',
has_audio: c.a === 1 ? true : c.a === 0 ? false : 'unknown',
has_open_bible_stories: !!c.o,
door43: Array.isArray(c.d) ? c.d : [],
checked_pools: c.ck || '',
unchecked_pools: c.un || '',
is_sign: c.sg === 1,
audio: c.au || null,
bible_video: c.bv || null,
gospel_tract: c.gp || null,
scripture_earth: c.se || null,
gospel_audio: c.ga || null,
bridge: pinBridges.value[`${r[4]}|${r[3]}`] || null,
bridge_candidate: pinCandidates.value[`${r[4]}|${r[3]}`] || null,
related: c.rel || null,
zume_url: c.z || '',
zume_approx: c.za || null,
scripture_title: c.sv || '',
scripture_scope: c.ss || '',
code_note: r[4] ? '' : 'This group has no ISO 639-3 code in the source, so no resource lookup is possible for it.',
}
}
// EVERY medium counts, not just the two this started with. While it read only
// scripture/film/door43, 559 people groups covering 61M people were filed under "No
// resource found" in the country view while their own pin sheet showed a live audio
// Scripture link — one map contradicting itself depending on which way you opened it.
// READ THE TIER. This listed the resource fields by hand and went stale every time a source
// was added — the FOURTH time this shape has appeared in this bundle. Measured before the
// fix: 729 groups, 77.9 million people, were filed under "no resources found" in the country
// sheet while the pin sheet offered them a working link, because Scripture Earth and GRN
// were missing from the list. The pin index already decides this and a gate guards it.
const hasAny = (r) => r._tier !== 0
// Resources first — a standing decision, not a default — then largest population inside
// each block.
const groupsForCountry = computed(() => {
const idx = pinsByIso.value[selected.value?.iso3] || []
const list = idx.map(groupAt).filter(Boolean)
return list.sort((a, b) =>
(hasAny(b) ? 1 : 0) - (hasAny(a) ? 1 : 0) || (b.population || 0) - (a.population || 0))
})
const withResources = computed(() => groupsForCountry.value.filter(hasAny))
const withoutResources = computed(() => groupsForCountry.value.filter((r) => !hasAny(r)))
// The header must count the SAME set the list below it shows. It used to read
// gospel-by-country.json, which is built from the curated 2,106-record subset, so after
// the list moved to the pins the card contradicted itself inside one view: "People
// groups 36" sitting directly above "Gospel resources found (282)" for Indonesia.
// Derived here from the same groupsForCountry the list renders, so the two cannot drift.
const countryStats = computed(() => {
const g = groupsForCountry.value
let scripture = 0, film = 0, audio = 0, none = 0, popNone = 0, sign = 0
for (const r of g) {
if (r.scripture_url) scripture++
if (r.film_url) film++
if (r.audio) audio++
if (!hasAny(r)) { none++; popNone += r.population || 0 }
if (r.is_sign) sign++
}
return {
groups: g.length, scripture, film, audio, none, popNone, sign,
pctNone: g.length ? Math.round((1000 * none) / g.length) / 10 : 0,
}
})
/* ITEM 2 — resources in a group's OWN language, joined on the EXACT ISO 639-3 code.
* Never a name match: language names collide and drift ("Arabic" is dozens of
* distinct codes), and a fuzzy hit here would put the wrong scripture in someone's
* hand while looking perfectly correct on screen. */
const sameLanguage = computed(() => {
const g = selectedGroup.value
const code = String(g?.iso639_3 || '').trim()
if (!code) return []
const out = []
for (let i = 0; i < pinRows.value.length; i++) {
const r = pinRows.value[i]
if (r[4] !== code) continue
if (r[0] === g.people_group && r[3] === (g._iso3 || r[3]) && i === g._i) continue
out.push(groupAt(i))
}
// Largest first, and capped by the template at 12 — a widely-spoken code like cmn
// otherwise lists 135 groups inside a 330px card.
return out.sort((a, b) => (b.population || 0) - (a.population || 0))
})
const verifyOf = (url) => (url && linkVerify.value ? linkVerify.value[url] : null) || null
/* labelKey, not label: a chip reads "<resource> · <state>" and BOTH halves are chrome, so
both come from the catalogue. The class stays as it is — it is styling, not language. */
const VSTATE = {
verified: { cls: 'ok', labelKey: 'gospel.state.linkChecked' },
broken: { cls: 'bad', labelKey: 'gospel.state.linkDead' },
mismatch: { cls: 'warn', labelKey: 'gospel.state.wrongLanguage' },
unknown: { cls: 'meh', labelKey: 'gospel.state.unchecked' },
}
// A URL with no verification entry is UNCHECKED — never quietly shown as working.
const vchip = (url) => VSTATE[verifyOf(url)?.state || 'unknown'] || VSTATE.unknown
const vtitle = (url) => verifyOf(url)?.detail || 'This link has not been checked.'
// A big field (India carries 856 groups) would otherwise render hundreds of buttons
// into a phone sheet on the first tap. Capped, with the real total always stated and
// one tap to see everything — a silent truncation would read as "that is all of them".
const PAGE = 40
const shown = ref(PAGE)
function openCountry(iso) {
if (!iso || !byIso.value[iso]) return
selected.value = byIso.value[iso]
selectedGroup.value = null
shown.value = PAGE
ensurePeopleGroups()
}
function openGroup(g) { selectedGroup.value = g }
function closeSheet() { selected.value = null; selectedGroup.value = null }
async function load() {
const [data, adm0, pinData, doxaData] = await Promise.all([
fetch(`${assetsBase.value}/tables/gospel-by-country.json`).then((r) => r.json()),
// world-adm0 now INCLUDES India, so there is one ADM0 file and no patched
// variant. It used to omit IND (the 100-list splits India into 10 provincial
// zones, so its geometry shipped only as gb-IND-ADM1.geojson) — which left the
// LARGEST field in the dataset, 856 people groups, as a hole on every map. The
// review caught it as "India seems to have no internet coverage". The fix was a
// second file, world-adm0-plus.geojson; that file is gone and India is fetched
// at ADM0 like every other country (data/source-boundaries.mjs).
fetch(`${assetsBase.value}/boundaries/world-adm0.geojson`).then((r) => r.json()),
// 650KB and it is the point of the map, so it loads with the boundaries rather
// than behind a tap. URLs are normalised into `codes` by ISO 639-3, so the 12,379
// pins carry a code rather than 12,379 copies of the same jesusfilm.org link.
fetch(`${assetsBase.value}/tables/people-group-pins.json`).then((r) => r.json()),
/* ═══ THE MAP DRAWS DOXA'S ROSTER, NOT EVERY PEOPLE GROUP ON EARTH ═══════════════
* people-group-pins.json stays exactly as it is — 12,379 groups, 8,437 verified
* links, the whole GRN sweep — because it is the RESEARCH RECORD and the resource
* index every sheet reads. What changed is what gets DRAWN. Doxa works the ~2,100
* groups on pray.doxa.life, and a map of everyone buries the ones they can reach.
* Nothing is lost; the wider set simply stops being dumped on the map. */
fetch(`${assetsBase.value}/tables/doxa-people-groups.json`).then((r) => r.json()),
])
rows.value = data.countries || []
pinCodes.value = pinData.codes || {}
pinBridges.value = pinData.bridges || {}
pinCandidates.value = pinData.bridgeCandidates || {}
/* Same seven-slot shape the rest of this file already speaks, so narrowing the roster
changes WHICH pins exist and nothing about how they are drawn, clicked or filtered. */
const roster = (doxaData.rows || [])
doxaTotal.value = roster.length
pinRows.value = roster
.filter((r) => r.mapped && r.lat != null && r.lng != null)
.map((r) => [r.name, r.lat, r.lng, r.iso3, r.iso639_3 || '', r.population || 0, r.tier ?? 0])
pinCount.value = pinRows.value.length
doxaUnplaced.value = doxaTotal.value - pinCount.value
/* Countries Doxa actually works, and how many of their groups have nothing. A country
outside this set is NOT "zero" — it is out of scope, and painting it at all was the
misleading part: the United States holds three Doxa groups, so one without a resource
read as 33% and turned the whole country amber. */
const tally = {}
for (const r of roster) {
const t = (tally[r.iso3] ||= { groups: 0, none: 0, people: 0 })
t.groups += 1
t.people += r.population || 0
if (r.mapped && r.tier === 0) t.none += 1
}
doxaByCountry.value = tally
return adm0
}
// Kept so a mode switch can recolour from the geometry already in hand rather than
// re-fetching 2.7MB of boundaries.
const adm0Ref = shallowRef(null)
function paint(map, adm0) {
const m = byIso.value
const feats = (adm0.features || []).map((f) => {
const iso = f.properties?.iso3
const d = m[iso]
return {
type: 'Feature',
properties: {
iso3: iso,
band: d?.band || 'none',
/* Keyed by ISO3, not by the old country row: scope now comes from Doxa's roster,
and a country they do not work gets no fill rather than a "covered" colour. */
color: featureColor(iso),
hasData: !!doxaByCountry.value[iso],
},
geometry: f.geometry,
}
})
const fc = { type: 'FeatureCollection', features: feats }
if (map.getSource('gr-src')) { map.getSource('gr-src').setData(fc); return }
map.addSource('gr-src', { type: 'geojson', data: fc })
map.addLayer({
id: 'gr-fill', type: 'fill', source: 'gr-src',
paint: { 'fill-color': ['get', 'color'], 'fill-opacity': ['case', ['get', 'hasData'], 0.72, 0] },
})
map.addLayer({
id: 'gr-line', type: 'line', source: 'gr-src',
paint: { 'line-color': 'rgba(255,255,255,0.5)', 'line-width': 0.5 },
})
// TAP, not hover. On a phone hover never fires, so a hover-only map is dead.
map.on('click', 'gr-fill', (e) => openCountry(e.features?.[0]?.properties?.iso3))
map.on('mouseenter', 'gr-fill', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'gr-fill', () => { map.getCanvas().style.cursor = '' })
}
function pinFC() {
const feats = new Array(pinRows.value.length)
const hnrFor = (code) => {
const c = pinCodes.value[code]
return !!c && canHear(c) && !hasTextScripture(c)
}
for (let i = 0; i < pinRows.value.length; i++) {
const p = pinRows.value[i]
// Only the INDEX travels in properties; the row itself stays in JS. Mapbox
// stringifies nested feature properties, so passing the record would ship a
// second copy of the table into the GL source.
feats[i] = {
type: 'Feature',
// hnr is baked so the filter stays a GL-side predicate like the others: recomputing it
// per toggle would re-upload 12,379 features to answer a yes/no already known here.
properties: { i, tier: p[6], color: PIN_COLOR[p[6]], hnr: hnrFor(p[4]) },
geometry: { type: 'Point', coordinates: [p[2], p[1]] },
}
}
return { type: 'FeatureCollection', features: feats }
}
function paintPins(map) {
if (!pinRows.value.length) return
if (map.getSource('gr-pin-src')) { map.getSource('gr-pin-src').setData(pinFC()); return }
map.addSource('gr-pin-src', { type: 'geojson', data: pinFC() })
map.addLayer({
id: 'gr-pins', type: 'circle', source: 'gr-pin-src',
// circle-sort-key is a LAYOUT property. Mapbox tolerates it in `paint`; MapLibre
// rejects the whole layer for one misplaced property — without throwing — so every
// pin on this map vanished under the keyless engine while the page still looked
// fine. Same defect, same fix, as all-people-groups. check:maplibre-drops is the
// gate that names it.
layout: {
// A group with NOTHING found draws ON TOP of a covered one. At world zoom the
// pins overlap heavily, and the whole purpose of this map is that the gap is
// the thing you cannot miss — so the gap wins the z-fight, by construction.
'circle-sort-key': ['match', ['get', 'tier'], 0, 3, 1, 2, 1],
},
paint: {
'circle-color': ['get', 'color'],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 0.4, 6, 0.8, 12, 1.1],
'circle-stroke-opacity': 0.85,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 1.8, 4, 3, 8, 5.5, 12, 8],
'circle-opacity': 0.92,
},
})
// TAP, not hover — same reason as the choropleth: hover never fires on a phone.
map.on('click', 'gr-pins', (e) => openPin(e.features?.[0]?.properties?.i))
// VERIFICATION HANDLE — same contract as DashboardMapPanel's window.__rdashMap. An
// agent checking this map otherwise has to guess screen coordinates to hit a 3px dot,
// which is how a gate ends up reporting a product defect for its own bad arithmetic.
// __grState() is the falsifiable one: it reports the counts the layer actually holds.
if (typeof window !== 'undefined') {
window.__grMap = map
window.__grOpenPin = (i) => openPin(i)
window.__grState = () => ({
pins: pinRows.value.length,
codes: Object.keys(pinCodes.value).length,
hasLayer: !!map.getLayer('gr-pins'),
hasSource: !!map.getSource('gr-pin-src'),
rendered: map.queryRenderedFeatures({ layers: ['gr-pins'] }).length,
// country view: what the sheet is actually listing, so a check can compare it
// against the pins the layer holds for the same country rather than counting DOM
// rows, which are paginated at 40.
country: selected.value?.iso3 || null,
countryGroups: groupsForCountry.value.length,
countryWith: withResources.value.length,
countryWithout: withoutResources.value.length,
pinsInCountry: (pinsByIso.value[selected.value?.iso3] || []).length,
group: selectedGroup.value,
})
}
map.on('mouseenter', 'gr-pins', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'gr-pins', () => { map.getCanvas().style.cursor = '' })
}
// A pin click opens the group detail AND sets the country context, because the sheet
// is `v-if="selected"` and its back button reads `selected.country`. Opening a group
// without a country would render nothing at all.
function openPin(i) {
const r = pinRows.value[i]
if (!r) return
// Link verification is needed by EVERY pin, so it is fetched unconditionally. It used
// to hang off the country branch below, which meant a tap landing before the country
// index had loaded left the whole session showing "unchecked" on links that were in
// fact verified — and "unchecked" is indistinguishable from "nobody looked".
ensurePeopleGroups()
const country = byIso.value[r[3]]
if (country) { selected.value = country; shown.value = PAGE }
else selected.value = { country: r[3], iso3: r[3] }
// ONE shape, built in one place. openPin used to assemble its own object, so a field
// added to the country-list shape silently did not exist on a pin tap.
selectedGroup.value = groupAt(i)
}
onMounted(async () => {
try { await ensureEngineScript(grProvider) } catch (_) { /* fall through to the notice below */ }
const gl = engineGlobal(grProvider) || window.mapboxgl
if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return }
try {
const adm0 = await load()
const map = new gl.Map({
container: mapEl.value,
// Paired through the registry so both engines show one intent: 'ink' is the near-black entry
// (mapbox dark-v11 / OpenFreeMap dark), 'paper' the near-white one (light-v11 / positron).
style: resolveNamedStyle(isDark.value ? 'ink' : 'paper', grProvider).style
|| (isDark.value ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/light-v11'),
accessToken: mapboxToken.value,
center: [42, 18], zoom: 1.3, projection: 'mercator', attributionControl: false,
})
mapRef.value = markRaw(map)
adm0Ref.value = markRaw(adm0)
// LICENCE OBLIGATION, not decoration. attributionControl:false above kills
// Mapbox's built-in credit and nothing replaced it — this map was shipping with
// NO attribution at all. It SHADES geoBoundaries polygons, so the geoBoundaries
// credit + link back is required, and gbOpen is mixed-licence with several
// countries carrying a real attribution requirement (India CC BY 2.5 IN, etc).
addMapAttribution(map, { boundaries: true })
map.once('load', () => { paint(map, adm0); paintPins(map); openFromUrl() })
} catch (e) { err.value = String(e.message || e) }
})
onBeforeUnmount(() => {
try { mapRef.value?.remove() } catch (_) {}
if (typeof window !== 'undefined') window.removeEventListener('resize', syncNarrow)
})
function setMode(m) {
if (mode.value === m) return
mode.value = m
if (mapRef.value && adm0Ref.value) paint(mapRef.value, adm0Ref.value)
}
const fmt = (n) => (Number(n) || 0).toLocaleString()
// The table lives on a SIBLING page, so "see all of these" is a URL. Built here rather
// than inline in the template so the group link and the country link cannot drift apart.
const tableLink = (q) => {
const p = new URLSearchParams({ tab: 'links', ...q })
return `../data-tables/index.html?${p.toString()}`
}
// When this map is composed ABOVE its own tables, "see all of these" must not be a
// navigation — the tables are already on screen and leaving the page throws the map away.
// Null on the standalone page, where the link is exactly right.
const composedFilter = inject('composedFilter', null)
const composed = computed(() => !!composedFilter)
function filterComposedTables(q) {
if (!composedFilter) return
composedFilter.value = { ...q, at: Date.now() }
// Bring the tables into view; without this the filter applies somewhere the reader
// cannot see and the click reads as having done nothing.
const host = document.querySelector('gospel-resources-dashboard, gospel-resources-map')
const root = host && host.shadowRoot
const tables = root && root.querySelector('.gd-tables')
if (tables && tables.scrollIntoView) tables.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
useShadowStyles(
'.gm{position:absolute;inset:0;overflow:hidden}' +
'.gm-map{position:absolute;inset:0}' +
// LEGEND: a BOUNDED, left-anchored panel — NOT a full-width strip.
// It used to be `left:8px;right:8px`, which is a strip spanning the whole map
// (MEASURED 1264px of a 1280px map, 98.75%). That collided with the floating
// control rail by construction: MapToolbar is inset-inline-end:3px with 32px
// buttons, so ANY box reaching right:8px intersects it at every viewport. This
// legend is the TALLER of the two siblings (127px — it carries the mode
// toggle), so it ran under FOUR of the five buttons where cities ran under two.
// The rail's z-index:10 beats this z-index:4, so the buttons stayed clickable
// and the defect read as "the legend underlaps the controls" — reported 3x.
// The fix imitates the shared SemanticTreeLegend, which has always done it
// correctly: `left:8px; width:var(--map-legend-width,380px)`. Bounded and
// left-anchored cannot reach the right rail, so the two can never overlap
// again — a geometric guarantee, not a tuned z-index.
// KEEP THIS BYTE-IDENTICAL to cities-internet/connectivity-map.vue: they are
// clones and the whole point of fixing them together is that they not diverge.
'.gm-legend{position:absolute;top:8px;left:8px;width:var(--map-legend-width,380px);' +
'max-width:calc(100% - 16px);box-sizing:border-box;z-index:4;padding:8px 10px;' +
'border-radius:10px;background:rgba(255,255,255,.94);color:#0f1419;' +
"font:12px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.16)}" +
// Narrow screens: the rail is still on the right, so the legend must stop short
// of it rather than run under it. 46px = 32px button + 3px inset + clearance.
'@media(max-width:480px){.gm-legend{width:auto;right:46px}}' +
'.is-dark .gm-legend{background:rgba(19,32,25,.95);color:#e5e7eb}' +
'.gm-title{font-weight:700;font-size:15.5px;margin-bottom:5px}' +
'.gm-keys{display:flex;flex-wrap:wrap;gap:5px 12px}' +
'.gm-key{display:flex;align-items:center;gap:5px;font-size:14px;white-space:nowrap}' +
// This one label is a phrase, not a legend word, so it is allowed to wrap rather than
// be clipped by the nowrap above.
'.gm-key.wide{white-space:normal;text-align:left;line-height:1.25}' +
'.gm-sw{width:12px;height:12px;border-radius:3px;flex:none}' +
'.gm-dot{width:10px;height:10px;border-radius:50%;flex:none;display:inline-block;' +
'border:1.4px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.18);vertical-align:-1px}' +
'.gm-pinkey{margin-top:7px;padding-top:6px;border-top:1px solid rgba(0,0,0,.12)}' +
'.is-dark .gm-pinkey{border-top-color:rgba(255,255,255,.16)}' +
'.gm-keys-btn{gap:4px 8px}' +
'.gm-kbtn{border:1px solid transparent;background:transparent;color:inherit;font:inherit;font-size:14px;' +
'padding:2px 6px;border-radius:999px;cursor:pointer;min-height:24px;display:inline-flex;align-items:center;gap:5px}' +
'.gm-kbtn:hover{background:#e9ebe6;border-color:#d1d4cd}' +
'.gm-kbtn.on{background:#3b463d;border-color:#3b463d;color:#fff}' +
'.gm-kbtn b{font-variant-numeric:tabular-nums;opacity:.75;font-weight:700}' +
'.gm-filtered{margin-top:5px;font-size:13.5px;line-height:1.45;color:#3b463d}' +
'.is-dark .gm-filtered{color:#e5e7eb}' +
'.gm-kclear{border:1px solid #d1d4cd;background:#fff;color:#3b463d;font:600 10.5px inherit;' +
'padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px;min-height:24px}' +
'.gm-pinkey-h{font-size:14px;font-weight:600;margin-bottom:4px}' +
'.gm-caveat{margin-top:5px;font-size:13.5px;opacity:.72;line-height:1.4}' +
'.gm-prose-note{opacity:.9;font-weight:600}' +
// DETAIL: a bottom sheet on a phone, a corner card on desktop.
// ON A PHONE THE SHEET IS THE PAGE, and the shared pin hint sits on top of it. The hint
// is a library component with z-index 2147482000 and a deliberate "only the reader closes
// it" rule, which is right for a bare map — but once a group sheet is open the invitation
// to tap a dot is both redundant and obstructive: measured at 390px it covered "Also speak
// German, Standard (25)" and the first row under it.
// Hidden rather than out-stacked. Raising this sheet above 2147482000 would be a z-index
// arms race with shared code, and the hint lives inside this component's own shadow root,
// so scoping it here costs nothing and changes nothing for any other map.
// Desktop is untouched: there the sheet is a 330px corner card and the two never collide.
'@media(max-width:699px){.gm.has-sheet .pin-hint{display:none}}' +
'.gm-sheet{position:absolute;left:0;right:0;bottom:0;z-index:6;max-height:62%;overflow-y:auto;' +
'background:#fff;color:#0f1419;border-radius:14px 14px 0 0;padding:14px 16px 22px;' +
"font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;box-shadow:0 -6px 26px rgba(0,0,0,.28)}" +
'@media(min-width:700px){.gm-sheet{left:auto;right:14px;bottom:14px;width:330px;border-radius:12px}}' +
'.is-dark .gm-sheet{background:#132019;color:#e5e7eb}' +
'.gm-sh-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}' +
'.gm-sh-name{font-weight:800;font-size:18px}' +
'.gm-x{margin-left:auto;border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
'font:600 12px inherit;padding:6px 12px;border-radius:8px;cursor:pointer;min-height:34px}' +
'.gm-stat{display:flex;justify-content:space-between;gap:12px;padding:4px 0;' +
'border-bottom:1px solid rgba(0,0,0,.07);font-variant-numeric:tabular-nums}' +
'.is-dark .gm-stat{border-bottom-color:rgba(255,255,255,.09)}' +
'.gm-stat b{font-weight:700}' +
'.gm-note{margin-top:9px;font-size:14px;line-height:1.5;opacity:.75}' +
'.gm-err{position:absolute;top:50%;left:0;right:0;text-align:center;font-size:16px;opacity:.7}' +
// MODE TOGGLE — sits in the legend, because the legend is what it changes.
'.gm-fold{width:100%;margin:0 0 6px;border:1px solid #d1d4cd;background:#f3f3f1;color:#3b463d;font:600 11px inherit;padding:6px 10px;border-radius:8px;cursor:pointer;min-height:32px}' +
'.gm-fold:hover{background:#e9ebe6}' +
'.gm-search{position:relative;margin-bottom:6px}' +
'.gm-q{width:100%;box-sizing:border-box;border:1px solid #d1d4cd;border-radius:8px;background:#fff;color:#3b463d;font:12px inherit;padding:7px 26px 7px 9px;min-height:32px}' +
'.gm-q:focus{outline:2px solid #92b195;outline-offset:1px}' +
'.gm-qx{position:absolute;right:4px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:#6d766e;font-size:19px;line-height:1;cursor:pointer;padding:4px 6px;min-height:24px}' +
'.gm-qres{margin:-2px 0 7px;border:1px solid #d1d4cd;border-radius:8px;background:#fff;overflow:hidden}' +
'.gm-qrow{display:flex;align-items:baseline;gap:6px;width:100%;text-align:left;border:0;background:transparent;font:inherit;font-size:14px;color:#3b463d;padding:6px 8px;cursor:pointer;min-height:30px;border-bottom:1px solid #eceee9}' +
'.gm-qrow:last-child{border-bottom:0}' +
'.gm-qrow:hover{background:#f3f3f1}' +
'.gm-qn{font-weight:700}' +
'.gm-ql{opacity:.7}' +
'.gm-qnone,.gm-qmore{padding:6px 8px;font-size:13.5px;color:#6d766e}' +
'.gm-modes{display:flex;gap:6px;margin-bottom:6px}' +
'.gm-mode{font:600 10.5px inherit;padding:4px 10px;border-radius:999px;cursor:pointer;' +
'border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;min-height:26px}' +
'.gm-mode.on{background:#0f1419;border-color:#0f1419;color:#fff}' +
'.is-dark .gm-mode{border-color:rgba(255,255,255,.22)}' +
'.is-dark .gm-mode.on{background:#10b981;border-color:#10b981;color:#04241a}' +
// RESOURCE LIST inside the sheet.
'.gm-scope{margin:7px 0 0;font-size:14.5px;line-height:1.45;color:#3b463d}' +
'.gm-scope b{font-weight:700}' +
'.is-dark .gm-scope{color:#e5e7eb}' +
// Same frame as the sign-language note: a caveat, visually separate from the chips so it
// never reads as another resource.
// Quiet, not alarming: it is a caveat on a real gap, not a dismissal of it.
// Five tabs no longer fit one row at phone width; wrapping keeps every label readable
// rather than clipping each to a few characters.
'.gm-gswitch{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px}' +
'.gm-gtab{min-width:88px}' +
'.gm-gtab{flex:1;font:600 11px inherit;padding:5px 8px;border-radius:999px;cursor:pointer;' +
'border:1px solid #d1d4cd;background:#fff;color:#3b463d}' +
'.gm-gtab.on{background:#92b195;border-color:#92b195;color:#fff}' +
'.is-dark .gm-gtab{background:transparent;border-color:rgba(255,255,255,.22);color:#e5e7eb}' +
'.is-dark .gm-gtab.on{background:#92b195;color:#10241a}' +
'.gm-pg-rel{display:block;margin-top:2px;font-size:13px;color:#6d766e;font-style:italic}' +
'.is-dark .gm-pg-rel{color:#9fb3a4}' +
'.gm-rel{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-left:3px solid #92b195;' +
'border-radius:6px;background:#fff;font-size:14.5px;line-height:1.5}' +
'.gm-rel code{font-size:14px;background:#f3f3f1;padding:1px 4px;border-radius:3px}' +
'.is-dark .gm-rel{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
'.gm-sign{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-left:3px solid #6d766e;' +
'border-radius:8px;background:#f3f3f1;color:#3b463d;font-size:14.5px;line-height:1.5}' +
'.is-dark .gm-sign{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
'.gm-pray{display:block;margin:8px 0 0;padding:9px 11px;border-radius:8px;text-decoration:none;' +
'background:#3b463d;color:#fff;font:600 12.5px inherit;text-align:center}' +
'.gm-pray:hover,.gm-pray:focus-visible{background:#2f382f}' +
'.gm-pray-n{display:block;margin-top:2px;font-weight:500;font-size:14px;opacity:.85}' +
'.gm-tablink{display:block;margin:10px 0 2px;padding:9px 12px;border:1px solid #d1d4cd;border-left:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-weight:600;font-size:15.5px;text-decoration:none;min-height:36px;box-sizing:border-box}' +
'.gm-tablink:hover{background:#e9ebe6}' +
'.is-dark .gm-tablink{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
'.gm-stat-alt span{opacity:.72}' +
'.gm-sec{margin-top:12px}' +
'.gm-sec-h{font-weight:800;font-size:15px;letter-spacing:.02em;margin-bottom:6px}' +
'.gm-load{padding:10px 0;font-size:14.5px;opacity:.65}' +
// .gm-pg is now the SHELL only. The resource rows are a <div> shell (so the chips
// inside can be real anchors) while the no-resource rows are still a <button>; both
// must look identical, so the shell carries the box and .gm-pg-btn/.gm-pg-open carry
// the interactive bits. cursor:pointer is NOT on the shell — on a resource row the
// gaps between the name and the chips are not clickable, and saying otherwise lies.
'.gm-pg{width:100%;box-sizing:border-box;text-align:left;display:block;padding:8px 9px;' +
'margin-bottom:6px;border-radius:8px;' +
'border:1px solid rgba(0,0,0,.10);background:rgba(0,0,0,.02);color:inherit;font:inherit}' +
'.is-dark .gm-pg{border-color:rgba(255,255,255,.10);background:rgba(255,255,255,.03)}' +
'.gm-pg-btn{cursor:pointer;text-decoration:none;color:inherit}' +
// The drill-in control on a resource row: a bare button that inherits the shell's
// look, so the row still reads as one tap-target for the name + language line.
'.gm-pg-open{display:block;width:100%;text-align:left;padding:0;margin:0;border:0;' +
'background:transparent;color:inherit;font:inherit;cursor:pointer}' +
'.gm-pg-n{display:block;font-weight:700;font-size:15.5px}' +
'.gm-pg-l{display:block;font-size:14px;opacity:.75;margin-top:1px}' +
'.gm-code{display:inline-block;font:600 10px ui-monospace,SFMono-Regular,Menlo,monospace;' +
'padding:1px 5px;border-radius:4px;background:rgba(0,0,0,.09);margin-left:5px;vertical-align:1px}' +
'.is-dark .gm-code{background:rgba(255,255,255,.12)}' +
'.gm-chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:5px}' +
// nowrap keeps the short status chips ("Film - link checked") on one line, but a Door43
// entry can be "Aligned Bible (translationCore-Create-BCS) - Door43 - Portions of both",
// which simply ran off the edge of a 330px sheet and could not be read at all. Long chips
// wrap instead; max-width keeps them inside the panel either way.
'.gm-chip{font-size:13px;padding:2px 7px;border-radius:999px;white-space:nowrap;max-width:100%;' +
'background:rgba(0,0,0,.06);text-decoration:none;color:inherit;border:1px solid transparent}' +
'.gm-chip.wrap{white-space:normal;overflow-wrap:anywhere;line-height:1.35}' +
'.is-dark .gm-chip{background:rgba(255,255,255,.08)}' +
'.gm-chip.ok{background:rgba(16,185,129,.16);border-color:rgba(16,185,129,.35)}' +
'.gm-chip.bad{background:rgba(180,35,24,.16);border-color:rgba(180,35,24,.38)}' +
'.gm-chip.warn{background:rgba(224,122,43,.18);border-color:rgba(224,122,43,.38)}' +
'.gm-chip.meh{background:rgba(120,130,140,.16);border-color:rgba(120,130,140,.30)}' +
// APPROXIMATE: the same green family as a confirmed chip, because the resource is real,
// but DASHED and unfilled so it never reads as the same claim at a glance.
'.gm-chip.approx{background:transparent;border-style:dashed;border-color:rgba(16,185,129,.55)}' +
'.gm-chip.approx em{font-style:normal;opacity:.7}' +
// Only an ANCHOR chip gets the pointer + underline. A <span> chip ("none found",
// "unknown") must keep looking inert, because it is.
'a.gm-chip{cursor:pointer}' +
'a.gm-chip:hover,a.gm-chip:focus-visible{text-decoration:underline}' +
'.gm-none{font-size:14px;opacity:.7}' +
'.gm-back{border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
'font:600 11px inherit;padding:5px 11px;border-radius:8px;cursor:pointer;min-height:30px}' +
'.is-dark .gm-back{border-color:rgba(255,255,255,.22)}' +
'.gm-more{font-size:14px;opacity:.66;margin-top:2px}',
'doxa-gospel-map',
)
</script>
<template>
<!-- THE ROOT FOLLOWS THE LOCALE; THE ENGLISH PROSE DOES NOT.
This root carried dir="ltr" while every string here was English — an RTL flip over
English text had clipped the legend title's first letter, rendered "30-59%" as
"59%-30", and thrown sentence-ending periods to the START of lines. The chrome is
translated now, so Arabic gets the RTL layout it should have, and the blanket
override is gone. What stays LTR is each block of English EXPLANATORY PROSE, marked
where it sits: those are still English by decision, and English inside an RTL box is
the defect this replaced. -->
<div class="gm" :class="{ 'is-dark': isDark, 'has-sheet': !!(selected || showGapList) }">
<div ref="mapEl" class="gm-map"></div>
<!-- Shared control rail — MapToolbar self-positions top-right. Zoom/Location/
Fullscreen gate on the live map instance; ShareButton needs neither and
always renders so the map is embeddable. -->
<MapToolbar v-if="mapRef">
<ZoomInButton :map="mapRef" :is-dark="isDark" />
<ZoomOutButton :map="mapRef" :is-dark="isDark" />
<LocationButton :map="mapRef" :is-dark="isDark" />
<FullscreenButton :map-container="mapEl" :is-dark="isDark" />
<ShareButton :is-dark="isDark" :embed-path="shareEmbedPath" />
</MapToolbar>
<div class="gm-legend">
<div class="gm-search">
<!-- The full phrase clips to "…ISO cod" at 390px, which loses the one word that tells
a reader an ISO CODE is accepted at all. The short form keeps all three things it
accepts; the aria-label stays long, because a screen reader has no width. -->
<input type="search" class="gm-q" v-model="q"
:placeholder="narrow ? t('gospel.search.placeholderShort') : t('gospel.search.placeholder')"
aria-label="Find a people group, language or ISO code">
<button v-if="q" type="button" class="gm-qx" @click="q = ''" :aria-label="t('gospel.search.clear')">&times;</button>
</div>
<div v-if="q.trim().length >= 2" class="gm-qres">
<div v-if="!matches.length" class="gm-qnone">{{ t('gospel.search.none') }}</div>
<template v-else>
<button v-for="m in matches.slice(0, 8)" :key="m.i" type="button" class="gm-qrow" @click="gotoMatch(m)">
<i class="gm-dot" :style="{ background: PIN_COLOR[m.tier] }"></i>
<span class="gm-qn">{{ m.name }}</span>
<span class="gm-ql">{{ m.lang || 'unnamed language' }} · {{ m.iso3 }}</span>
</button>
<div v-if="matches.length > 8" class="gm-qmore">Showing 8 of {{ fmt(matches.length) }} matches — keep typing to narrow.</div>
</template>
</div>
<div class="gm-modes">
<button type="button" class="gm-mode" :class="{ on: mode === 'gap' }" @click="setMode('gap')">{{ t('gospel.mode.gap') }}</button>
<button type="button" class="gm-mode" :class="{ on: mode === 'coverage' }" @click="setMode('coverage')">{{ t('gospel.mode.coverage') }}</button>
</div>
<button v-if="narrow" type="button" class="gm-fold" @click="legendOpen = !legendOpen"
:aria-expanded="legendOpen ? 'true' : 'false'">
<!-- "Key & notes" advertised only half of what is behind it. This panel also holds
the pin FILTERS — by design, because the key doubles as the filter (see below) —
so on a phone the whole gap workflow sat behind a label that sounded like
documentation. Reaching the ranked gap list means opening this first, and a
reader with no reason to open "notes" never finds it. -->
{{ legendOpen ? 'Hide key &amp; filters' : 'Key &amp; filters' }}
</button>
<div v-show="!narrow || legendOpen" class="gm-title">
{{ mode === 'gap'
? t('gospel.legend.title')
: t('gospel.legend.titleCoverage') }}
</div>
<div v-show="!narrow || legendOpen" class="gm-keys">
<span v-for="b in activeBands" :key="b.id" class="gm-key">
<i class="gm-sw" :style="{ background: b.color }"></i>{{ t(b.labelKey) }}
</span>
</div>
<!-- The tally sentence is still English prose, so it declares its own direction
like the caveat below it. -->
<div v-show="!narrow || legendOpen" class="gm-pinkey" v-if="pinTally.total">
<!-- Translated rather than pinned LTR. Pinning it left-aligned an English sentence
inside the RTL panel and the second line ran off the left edge — "th a link".
It is a COUNT, not doctrinal prose, so it belongs in the catalogue with the
rest of the chrome, and then the bidi problem does not exist. -->
<div class="gm-pinkey-h">
{{ t('gospel.legend.tallyEach') }} <i class="gm-dot" style="background:#6d766e"></i>
{{ t('gospel.legend.tallyIs') }}
{{ fmt(pinTally.total) }} {{ t('gospel.legend.tallyOf') }}
{{ fmt(pinTally.linked) }} {{ t('gospel.legend.tallyLink') }}
</div>
<!-- The key IS the filter: a reader who has just understood what a colour means is
exactly the reader who wants to see only that colour. Two controls would be one
too many. -->
<div v-show="!narrow || legendOpen" class="gm-keys gm-keys-btn">
<button type="button" class="gm-key gm-kbtn" :class="{ on: pinFilter === 'both' }"
@click="setPinFilter(pinFilter === 'both' ? 'all' : 'both')"
:aria-pressed="pinFilter === 'both'">
<i class="gm-dot" :style="{ background: PIN_COLOR[2] }"></i>{{ t('gospel.legend.tier2') }}
<b>{{ fmt(pinTally.both) }}</b>
</button>
<button type="button" class="gm-key gm-kbtn" :class="{ on: pinFilter === 'some' }"
@click="setPinFilter(pinFilter === 'some' ? 'all' : 'some')"
:aria-pressed="pinFilter === 'some'">
<i class="gm-dot" :style="{ background: PIN_COLOR[1] }"></i>{{ t('gospel.legend.tier1') }}
<b>{{ fmt(pinTally.partial) }}</b>
</button>
<button type="button" class="gm-key gm-kbtn" :class="{ on: pinFilter === 'none' }"
@click="setPinFilter(pinFilter === 'none' ? 'all' : 'none')"
:aria-pressed="pinFilter === 'none'">
<i class="gm-dot" :style="{ background: PIN_COLOR[0] }"></i>{{ t('gospel.legend.tier0') }}
<b>{{ fmt(pinTally.none) }}</b>
</button>
<!-- Not a colour, so it carries no dot: this cuts ACROSS the three tiers rather
than naming one of them. It is the translation-priority question. -->
<button type="button" class="gm-key gm-kbtn wide" :class="{ on: pinFilter === 'hnr' }"
@click="setPinFilter(pinFilter === 'hnr' ? 'all' : 'hnr')"
:aria-pressed="pinFilter === 'hnr'"
title="Groups with recorded audio, the JESUS Film, a Bible video or Words of Life, but no text Scripture found in the pools checked. Not a claim that none exists.">
{{ t('gospel.legend.hnr') }}
<b>{{ fmt(pinTally.hnr) }}</b>
</button>
</div>
<div v-if="pinFilter !== 'all' && (!narrow || legendOpen)" class="gm-filtered">
Showing only <b>{{ pinFilter === 'none' ? 'groups with nothing found'
: pinFilter === 'some' ? 'groups with some resource'
: pinFilter === 'hnr' ? 'groups that can hear the gospel but have no text Scripture in the pools checked'
: '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>
<!-- The ONE translated line about the English below it. Rendered outside the LTR
block so it reads in the reader's own direction, and only when the locale is
not English — telling an English reader that the English is English is noise. -->
<div v-show="!narrow || legendOpen" v-if="!isEnglish" class="gm-caveat gm-prose-note">
{{ t('gospel.prose.english') }}
</div>
<div v-show="!narrow || legendOpen" class="gm-caveat" dir="ltr">
<!-- The colour and the dots are now computed from the SAME index. They used to be
two datasets: the fill came from a curated set that had one group for Papua New
Guinea while the map drew 831 pins there, so 135 of 170 countries were coloured
by something the dots on top of them contradicted. -->
<!-- The three SHORT instruction lines are translated; the long prose around them stays
English by decision. They are what a reader needs to use the map at all — what a
dot is, what "none found" does not mean, and that a country is tappable — so
leaving them English made the map unusable rather than merely untranslated. -->
<b>{{ t('gospel.guide.dotVsCountry') }}</b>
<span dir="ltr"> A country is a COUNT of those groups — how many of
Doxa's are there with nothing found — never a percentage: 106 of these 175 countries hold
one or two groups, and a share over a denominator of two says nothing. A country Doxa does
not work is left UNCOLOURED rather than drawn as covered, because this dataset has no
finding about it either way.</span>
<br><b>{{ t('gospel.guide.noneFoundShort') }}</b>
<span dir="ltr">The pools checked are eBible, PNG.Bible, JESUS Film, Door43,
Faith Comes By Hearing, Scripture Earth and Global Recordings Network). GRN is reached
through their 5fish site, and that sweep is now <b>finished for every group in the gap</b>
— every language with nothing else was asked for, at the ten-second crawl delay they
request — so “nothing found” no longer rests on an unread pool. Codes that already carry
a resource were not all asked, because the sweep was spent on the gap first. What is still
unread is api.bible, the Digital Bible Library, progress.bible and YouVersion, which need
credentials or permission this build does not have. Never proof of absence.
<template v-if="mode === 'coverage'">
A “found” colour is equally bounded: it counts what these pools list, not everything that exists.
</template></span>
<br><b>{{ t('gospel.guide.tapCountry') }}</b>
</div>
</div>
<div v-if="err" class="gm-err">{{ err }}</div>
<!-- Tap a country: bottom sheet on mobile, corner card on desktop. -->
<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">{{ gapBy === 'groups' ? 'Largest groups with nothing found'
: gapBy === 'noprayer' ? 'Nothing found, and nobody praying'
: gapBy === 'hnr' ? 'Can hear the gospel, no text to read'
: 'Where the gap concentrates' }}</span>
<button type="button" class="gm-x" @click="closeGapList">{{ t('gospel.sheet.close') }}</button>
</div>
<p class="gm-note" dir="ltr" style="margin-top:0">
<!-- The hnr tab describes a DIFFERENT population from the other three, so it must
not inherit the nothing-found sentence: those groups do have something. -->
<template v-if="gapBy === 'hnr'">
{{ fmt(pinTally.hnr) }} people groups can already hear the gospel — a recording,
the JESUS Film, a Bible video or Words of Life — but have <b>no text Scripture
found in the pools checked</b>. These are the {{ gapHnr.length }} largest; tap one
to fly there.
<br>That is a statement about the pools searched, <b>not</b> a claim no text
exists. The largest entries here are varieties like Western Punjabi and
Shanghainese, where a recording plainly exists and a text in that same variety is
genuinely contested.
</template>
<template v-else>
{{ fmt(pinTally.none) }} people groups have no Scripture, audio, film, Bible video or
Door43 resource in the pools checked — {{ fmt(gapPeople) }} people. </template>
<template v-if="gapBy === 'noprayer'">
The groups with nothing found in any pool checked AND nobody praying —
{{ fmt(gapNoPrayer.length) }} shown, largest first. Tapping one opens their prayer
page on doxa.life.
<br><b>"Nobody praying" means zero on doxa.life's counter</b>, not a claim that
nobody in the world prays for them. Only the {{ fmt(Object.keys(prayIndex).length) }}
groups doxa.life publishes a page for can appear here at all.
</template>
<template v-else-if="gapBy === 'regions'">
The five UN regions, ranked by the SHARE of their groups with nothing — the one
question the other two tabs cannot answer. Oceania is half unreached by group
and small by population; Asia is the reverse. Rate, count and population each
point somewhere different, and all three are here rather than one chosen for you.
</template>
<template v-else-if="gapBy === 'countries'">
The top 40 countries by how many of those groups they hold; tap one to open it.
The two columns disagree on purpose — Papua New Guinea has the most groups and
few people, China the reverse — and which one matters depends on whether you are
sending translators or broadcasters.
</template>
<template v-else-if="gapBy !== 'hnr'">These are the 40 largest; tap one to fly there.</template>
<template v-if="gapBy === 'groups'">
<br><b>What that number is made of.</b> {{ gapKindsWord }} different situations share
it, and only the first is a translation problem:
<template v-if="gapMakeup.searched">
<br>· <b>{{ fmt(gapMakeup.searched) }}</b> groups ({{ fmt(gapMakeup.searchedP) }} people)
— every pool this build can reach was checked and found nothing. This is the real gap.
</template>
<template v-if="gapMakeup.pending">
<br>· <b>{{ fmt(gapMakeup.pending) }}</b> ({{ fmt(gapMakeup.pendingP) }}) — Global
Recordings Network has not been swept for them yet; the crawl runs a batch at a time.
</template>
<template v-if="gapMakeup.sign">
<br>· <b>{{ fmt(gapMakeup.sign) }}</b> ({{ fmt(gapMakeup.signP) }}) — sign languages,
whose pools are key-gated. The largest blocked population here, and an access
problem rather than a translation one. <template v-if="gapMakeup.signPriv">{{ fmt(gapMakeup.signPriv) }}
of them also carry a code no pool can index, so they are doubly stuck. </template>
</template>
<template v-if="gapMakeup.priv + gapMakeup.noCode">
<br>· <b>{{ fmt(gapMakeup.priv + gapMakeup.noCode) }}</b>
({{ fmt(gapMakeup.privP + gapMakeup.noCodeP) }}) — a private-use code or none at
all, which no pool can index. Searching harder cannot move these.
</template>
</template>
<!-- This paragraph has twice had to stop claiming a pool was unsearched: first when
Faith Comes By Hearing was added, then when GRN was reached through 5fish. A
stale caveat is as misleading as a stale number. -->
<template v-if="gapBy !== 'hnr'">
<br>Audio <b>has</b> been searched — at Faith Comes By Hearing, and at Global
Recordings Network through their 5fish site, whose sweep is finished for every group
in this list, at the ten-second delay they ask crawlers for. These groups have none in
either.
</template>
<template v-if="gapBy !== 'hnr'">
<br><em>related:</em> where a line below carries it, ISO 639-3 files that code under a
macrolanguage with another variety that does have resources. It is how the standard
groups the codes, not a claim they are mutually intelligible.
</template>
</p>
<div class="gm-gswitch">
<button type="button" class="gm-gtab" :class="{ on: gapBy === 'groups' }"
@click="gapBy = 'groups'">{{ t('gospel.tabs.groups') }}</button>
<button type="button" class="gm-gtab" :class="{ on: gapBy === 'countries' }"
@click="gapBy = 'countries'">{{ t('gospel.tabs.countries') }}</button>
<button type="button" class="gm-gtab" :class="{ on: gapBy === 'regions' }"
@click="gapBy = 'regions'">{{ t('gospel.tabs.regions') }}</button>
<button v-if="gapNoPrayer.length" type="button" class="gm-gtab" :class="{ on: gapBy === 'noprayer' }"
@click="gapBy = 'noprayer'">{{ t('gospel.tabs.noprayer') }}</button>
<button v-if="gapHnr.length" type="button" class="gm-gtab" :class="{ on: gapBy === 'hnr' }"
@click="gapBy = 'hnr'">{{ t('gospel.tabs.hnr') }}</button>
</div>
<!-- Links, not fly-to buttons: every row here HAS a prayer page, so the useful
action is to open it rather than to look at the dot again. -->
<!-- Fly-to buttons like the nothing-found list: unlike the prayer tab these rows
have no single URL to open — a group's resources live in its own sheet. -->
<template v-if="gapBy === 'hnr'">
<button v-for="g in gapHnr" :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>
<span class="gm-pg-rel">has {{ g.has }} — no text found</span>
</button>
</template>
<template v-else-if="gapBy === 'noprayer'">
<a v-for="g in gapNoPrayer" :key="g.i" class="gm-pg gm-pg-btn" :href="g.url"
target="_blank" rel="noopener noreferrer">
<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>
<span class="gm-pg-rel">nothing found, and nobody praying — open their prayer page ↗</span>
</a>
</template>
<!-- Not buttons: a region is not a place this map can fly to, and a control that
looks clickable and does nothing is worse than a plain row. -->
<template v-else-if="gapBy === 'regions'">
<div v-for="r in gapRegions" :key="r.region" class="gm-pg">
<span class="gm-pg-n">{{ r.region }}</span>
<span class="gm-pg-l"><b>{{ r.pct }}%</b> of its {{ fmt(r.groups) }} groups have nothing
· {{ fmt(r.none) }} groups · {{ fmt(r.pop) }} people</span>
</div>
</template>
<template v-else-if="gapBy === 'countries'">
<button v-for="c in gapCountries" :key="c.iso3" type="button" class="gm-pg gm-pg-btn"
@click="closeGapList(); openCountry(c.iso3)">
<span class="gm-pg-n">{{ c.country }}</span>
<span class="gm-pg-l">{{ fmt(c.groups) }} groups with nothing · {{ fmt(c.pop) }} people<code class="gm-code">{{ c.iso3 }}</code></span>
</button>
</template>
<button v-else 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>
<span v-if="g.rel" class="gm-pg-rel"
:title="'ISO 639-3 files this under macrolanguage ' + g.rel.m + ' with ' + g.rel.l + ', which has resources. That is how the standard groups the codes, not a claim they are mutually intelligible.'">
related: {{ g.rel.l }} has resources
</span>
</button>
</template>
<!-- ITEM 2 — one people group: what exists in THEIR OWN language. -->
<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">{{ t('gospel.sheet.close') }}</button>
</div>
<div class="gm-sh-name" style="margin-bottom:6px">{{ selectedGroup.people_group }}</div>
<div class="gm-stat">
<span>{{ t('gospel.stat.language') }}</span>
<b>{{ selectedGroup.language }}<code class="gm-code">{{ selectedGroup.iso639_3 || 'no code' }}</code></b>
</div>
<div class="gm-stat"><span>{{ t('gospel.stat.population') }}</span><b>{{ fmt(selectedGroup.population) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.countries') }}</span><b>{{ selectedGroup.countries }}</b></div>
<div class="gm-sec">
<div class="gm-sec-h">{{ t('gospel.sheet.ownLanguage') }}</div>
<div class="gm-chips">
<a v-if="selectedGroup.scripture_url" class="gm-chip" :class="vchip(selectedGroup.scripture_url).cls"
:href="selectedGroup.scripture_url" target="_blank" rel="noopener noreferrer"
:title="vtitle(selectedGroup.scripture_url)">
{{ t('gospel.res.scripture') }} · {{ t(vchip(selectedGroup.scripture_url).labelKey) }}
</a>
<span v-else class="gm-chip meh">{{ t('gospel.res.scripture') }} · {{ t('gospel.state.noneFound') }}</span>
<!-- The title says what the evidence IS. The JESUS Film Project's own API
confirms a feature film in this language; the watch URL opens their site
but is NOT a language deep link — measured in a browser, a real id and a
bogus id render the same library page. Promising more than that in a
tooltip is how a field worker follows a link and finds nothing. -->
<a v-if="selectedGroup.film_url" class="gm-chip" :class="vchip(selectedGroup.film_url).cls"
:href="selectedGroup.film_url" target="_blank" rel="noopener noreferrer"
:title="/[?&]languageId=/.test(selectedGroup.film_url)
? 'The JESUS Film Project lists a feature film in this language, but no per-language link is published for it — this opens their site and you may need to choose the language there.'
: 'Opens the JESUS Film in this language on jesusfilm.org.'">
{{ selectedGroup.film_title || t('gospel.res.film') }} · {{ t(vchip(selectedGroup.film_url).labelKey) }}
</a>
<span v-else class="gm-chip meh">{{ t('gospel.res.film') }} · {{ t('gospel.state.noneFound') }}</span>
<!-- AUDIO SCRIPTURE, from Faith Comes By Hearing. This chip read "not searched
(GRN)" on every group for as long as this map existed. GRN is now searched too,
through 5fish, and appears as its own Gospel audio chip — so this one speaks
only for FCBH and no longer implies the audio question went unasked. -->
<!-- ONE link, and it says what it carries. A bible.is fileset is a single page
that may hold audio, text or both, so two chips to the same URL would be
noise; the media list is the useful part, because "there is a recording"
and "there is a readable text" are different answers to different needs. -->
<a v-if="selectedGroup.audio" class="gm-chip ok wrap"
:href="selectedGroup.audio.u" target="_blank" rel="noopener noreferrer"
:title="selectedGroup.audio.n + ' — Scripture from Faith Comes By Hearing (' + (selectedGroup.audio.m || 'audio') + '), free in a browser.'
+ (selectedGroup.audio.vsc ? ' Video covers: ' + selectedGroup.audio.vsc + '. That is Bible video, not the JESUS Film.' : '')">
Scripture, {{ selectedGroup.audio.m || 'audio' }} · {{ selectedGroup.audio.n }}
</a>
<!-- Not shown for a sign language. Telling a Deaf group that no AUDIO was found
is noise about a medium they cannot use, and it pushes the one line that
matters — which pool could still have signed Scripture — further down. When
FCBH does hold something for them it is text, and the link above still shows. -->
<span v-else-if="!selectedGroup.is_sign" class="gm-chip meh"
title="Faith Comes By Hearing publishes no audio or text Scripture in this language. Global Recordings Network is searched separately and appears as its own Gospel audio chip when they hold something.">
{{ t('gospel.res.audio') }} · {{ t('gospel.state.noneAtFcbh') }}
</span>
<!-- Bible VIDEO, kept visibly apart from Film. Film here means the JESUS Film;
this is FCBH's Bible narrative, and its real book scope is shown because
"video" alone does not tell anyone whether they are getting Genesis or Acts. -->
<a v-if="selectedGroup.bible_video" class="gm-chip ok wrap"
:href="selectedGroup.bible_video.u" target="_blank" rel="noopener noreferrer"
:title="selectedGroup.bible_video.n + ' — Bible narrative video from Faith Comes By Hearing. This is NOT the JESUS Film.'">
Bible video · {{ selectedGroup.bible_video.sc || selectedGroup.bible_video.n }}
</a>
<!-- Door43 entries are real published repos, so they ship as real links. The
SUBJECT is shown verbatim: "Open Bible Stories" is 50 illustrated narratives,
not a Bible, and calling it Scripture would be the one lie that matters here. -->
<a v-for="d in selectedGroup.door43" :key="d.u" class="gm-chip ok wrap"
:href="d.u" target="_blank" rel="noopener noreferrer"
:title="d.s + ' — published on Door43 (unfoldingWord), open licence'">
{{ /^Open Bible Stories/.test(d.s) ? d.s.replace('Open Bible Stories', 'Bible Stories') : d.s }} · Door43<template v-if="d.sc"> · {{ d.sc }}</template>
</a>
<span v-if="!selectedGroup.door43.length" class="gm-chip meh">
{{ t('gospel.res.bibleStories') }} · {{ t('gospel.state.noneFound') }}
</span>
<!-- A GOSPEL PRESENTATION — the short translated thing you actually open with
someone. Not Scripture, not film, not a course, so it gets its own chip. It
closes no gap: every language carrying one already has something else. -->
<a v-if="selectedGroup.gospel_tract" class="gm-chip ok wrap"
:href="selectedGroup.gospel_tract.u" target="_blank" rel="noopener noreferrer"
:title="selectedGroup.gospel_tract.n + ' — a gospel presentation from Cru (GodTools), free to read in a browser in this language.'">
Gospel presentation · {{ selectedGroup.gospel_tract.n }}
</a>
<!-- GOSPEL AUDIO — for a language with no writing system this may be the only
form the gospel can take, so it gets its own chip rather than being folded
into the audio-Scripture one. The title says plainly that it is not a
translation: a worker must not set out believing they have Scripture. -->
<a v-if="selectedGroup.gospel_audio" class="gm-chip ok wrap"
:href="selectedGroup.gospel_audio.u" target="_blank" rel="noopener noreferrer"
title="Global Recordings Network publishes gospel audio in this language — usually Words of Life, gospel messages and Bible stories with songs. It is not a translation of the Bible.">
{{ t('gospel.res.gospelAudio') }} · GRN
</a>
<!-- THE ABSENT CASE, which was missing while five other pools had one. For a group
in the gap this sheet listed "none found" for Scripture, film, FCBH audio,
Bible Stories and Zume, and said NOTHING about Global Recordings Network —
the pool that for 1,220 groups is the only thing there is. Silence there does
not read as "searched and empty", it reads as "nobody asked", which is exactly
the impression the sweep was run to remove.
Not shown for a sign language: GRN publishes audio, so they are skipped on
purpose and "none found" would describe a search that was never the right one
— the same rule the FCBH chip above follows. -->
<span v-else-if="!selectedGroup.is_sign && poolSearched(selectedGroup, 'grn')"
class="gm-chip meh"
title="Global Recordings Network was asked for this language through their 5fish site and holds nothing in it. Shown only when they were actually asked — the sweep ran against the gap first, so a language that already has resources may not have been.">
{{ t('gospel.res.gospelAudio') }} · {{ t('gospel.state.noneAtGrn') }}
</span>
<!-- SCRIPTURE EARTH — SIL and its translation partners, reaching small languages
the big catalogues skip. The chip names WHAT IS THERE rather than saying just
"Scripture Earth", because the kinds differ per language and a worker needs to
know whether they are being sent to a PDF or to recorded audio. Codes carrying
only Jesus Film metadata stubs never reach this field. -->
<a v-if="selectedGroup.scripture_earth" class="gm-chip ok wrap"
:href="selectedGroup.scripture_earth.u" target="_blank" rel="noopener noreferrer"
title="Scripture Earth (SIL International and its translation partners) publishes Scripture in this language. The listing shows every file they hold for it.">
{{ t('gospel.res.scriptureEarth') }} · {{ seKinds(selectedGroup.scripture_earth.k) }}
</a>
<!-- Shown for a sign language too, unlike the audio chips: Scripture Earth
publishes text, PDFs and apps, so its absence is a real answer for a Deaf
group rather than noise about a medium they cannot use. -->
<span v-else-if="poolSearched(selectedGroup, 'scriptureearth')" class="gm-chip meh"
title="Scripture Earth (SIL International and its translation partners) holds nothing under this language code. That sweep covered every code this map carries, so it is a searched-and-empty result.">
{{ t('gospel.res.scriptureEarth') }} · {{ t('gospel.state.noneFound') }}
</span>
<!-- Zume is DISCIPLESHIP TRAINING, not Scripture and not film, so it sits here
as its own thing and moves no tier. The absent case names the denominator
rather than saying "none found": Zume is one platform in 54 languages, not
a pool that was searched exhaustively, and the two are not the same claim. -->
<a v-if="selectedGroup.zume_url" class="gm-chip ok"
:href="selectedGroup.zume_url" target="_blank" rel="noopener noreferrer"
title="Zume Training publishes its discipleship course in this language — free, no account needed to read it.">
{{ t('gospel.res.training') }} · Zume
</a>
<!-- APPROXIMATE, and it says so on its face. Zume publishes a written course
in this language but does not state which variety, and this group speaks
one of several. Saying "not in Zume's 54" here was false for 13 groups
covering 47.6M speakers; saying "Zume" flat would have been an overclaim.
The chip is styled apart from a confirmed one so the two never read alike. -->
<a v-else-if="selectedGroup.zume_approx" class="gm-chip approx"
:href="selectedGroup.zume_approx.u" target="_blank" rel="noopener noreferrer"
:title="selectedGroup.zume_approx.n + '. The course is written in one standard script, so it is likely to be readable — but it was not published against this ISO code and is shown as an approximate match, not a confirmed one.'">
{{ t('gospel.res.training') }} · Zume, {{ t('gospel.state.inLanguage') }} {{ selectedGroup.zume_approx.l }} <em>{{ t('gospel.state.varietyNotStated') }}</em>
</a>
<span v-else class="gm-chip meh"
title="Zume Training publishes in 54 languages and this is not one of them. That is a gap in one training platform, not a statement about every training resource.">
{{ t('gospel.res.training') }} · {{ t('gospel.state.notInZume') }}
</span>
</div>
<!-- AN IN-COUNTRY BRIDGE, and only for a group with nothing of their own. Worded
so it cannot be skimmed as "they have Scripture": the heading says NOT their
language, the body names whose language it is, and the gap above is
untouched. A bridge is a way in, not a translation. -->
<div v-if="selectedGroup.bridge && !selectedGroup.scripture_url && !selectedGroup.film_url
&& !selectedGroup.audio && !selectedGroup.gospel_audio && !selectedGroup.scripture_earth"
class="gm-sign" style="border-left-color:#92b195" dir="ltr">
<b>Not their language — but spoken around them.</b>
<br>Nothing was found in {{ selectedGroup.language || 'their language' }}. In this
country, <b>{{ selectedGroup.bridge.l }}</b> is spoken by
{{ selectedGroup.bridge.n }} people groups and does have
<template v-if="selectedGroup.bridge.s && selectedGroup.bridge.f">Scripture and the JESUS Film</template>
<template v-else-if="selectedGroup.bridge.s">Scripture</template>
<template v-else>the JESUS Film</template>.
<br><em>Bridge strength: {{ selectedGroup.bridge.st }}.</em> A second language is a
way in, not a translation — the gap in their own language is unchanged.
</div>
<!-- THE WEAKER CASE, and it says so on its face. No curated bridge exists for this
group, but their country does have a language carrying Scripture and the film.
No strength is shown because none can honestly be computed: ranking needs "is
this a lingua franca", and by population share a 10% floor refuses Urdu in
Pakistan while by group count Oman picks Swahili for an Arabic-speaking people.
The evidence is shown instead and the reader judges. Never for a sign language:
a spoken bridge is no bridge for a Deaf community. -->
<div v-else-if="selectedGroup.bridge_candidate && !selectedGroup.is_sign
&& !selectedGroup.scripture_url && !selectedGroup.film_url
&& !selectedGroup.audio && !selectedGroup.gospel_audio && !selectedGroup.scripture_earth"
class="gm-sign" style="border-left-color:#d1d4cd" dir="ltr">
<b>Possibly a way in — worth checking locally.</b>
<br>Nothing was found in {{ selectedGroup.language || 'their language' }}. The largest
language in this country that does have Scripture and the JESUS Film is
<b>{{ selectedGroup.bridge_candidate.l }}</b>, spoken by
{{ selectedGroup.bridge_candidate.n }} people group<template v-if="selectedGroup.bridge_candidate.n !== 1">s</template>
here ({{ selectedGroup.bridge_candidate.pct }}% of this country's mapped population).
<br><em>Not ranked and not recommended</em> — whether it actually reaches this group
is a local question, and the gap in their own language is unchanged.
</div>
<p v-if="selectedGroup.scripture_title || selectedGroup.scripture_scope" class="gm-scope">
<b>{{ selectedGroup.scripture_title || 'Scripture' }}</b>
<template v-if="selectedGroup.scripture_scope"> — {{ selectedGroup.scripture_scope }}</template>
</p>
<!-- A related variety that DOES have something. This is the note that stops the
gap list being read top-down into the wrong places: Jinyu at 60.3M and Gan at
41.3M are the two largest "nothing found" entries on this map, and ISO files
both under `zho` beside Mandarin, which has everything.
It deliberately does NOT claim intelligibility — spoken Jinyu and spoken
Mandarin are not mutually intelligible, they share a written standard — and it
changes no tier. The group still has nothing in its own variety. -->
<p v-if="selectedGroup.related" class="gm-rel">
<b>Nothing in {{ selectedGroup.language || 'this variety' }} itself</b> — but ISO 639-3
files it under the macrolanguage <code>{{ selectedGroup.related.m }}</code> together
with <b>{{ selectedGroup.related.l }}</b>, which does have resources here.
<br><br>
That is a statement about how the standard groups these codes, <em>not</em> a claim
that the two are mutually intelligible — often what they share is a written
standard rather than speech. It closes no gap and changes nothing above. It is here
because the largest "nothing found" entries on this map are varieties of this kind,
and a list read top-down would send you to them first.
</p>
<p v-if="selectedGroup.is_sign" class="gm-sign">
<b>This is a sign language.</b> A Deaf group cannot be reached by a text
translation, so eBible's silence here is not an absence of Scripture — it is a
text pool that was never the right place to look. What counts is Scripture
<em>filmed in this sign language</em>. Of {{ fmt(signTally.langs) }} sign languages
on this map, covering {{ fmt(signTally.people) }} people,
{{ signTally.both ? fmt(signTally.both) + ' have' : 'none yet has' }} both a film and
a text Scripture.
<br><br>
<!-- Naming the pool that could still have it is the useful half. Measured: the
JESUS Film catalogue lists 14 sign languages and every one is already on
this map, and Faith Comes By Hearing carries none at all — so the open
catalogues are exhausted here, and this gap closes through an agreement
rather than more searching. -->
The open catalogues are <b>exhausted</b> for sign language: the JESUS Film lists
14 entries across 13 language codes and every one is already here, and Faith
Comes By Hearing carries none at all. What is unchecked is <b>Deaf Bible
Society</b> and <b>DOOR International</b>, both of which need a key or an
agreement — this gap closes by asking them, not by searching harder.
</p>
</div>
<!-- THE MAP'S OWN PROMPT, ANSWERED. Its toast has always said "someone still needs
prayer" and tapping a dot offered no way to pray. doxa.life publishes a page per
group and sibling plugins here already link to it; this map simply did not.
Only shown where the join is unambiguous — 1,723 of 12,379 groups. -->
<a v-if="selectedPray" class="gm-pray" :href="selectedPray.u"
target="_blank" rel="noopener noreferrer"
:title="'Open the prayer page for ' + selectedPray.n + ' on doxa.life.'">
{{ t('gospel.sheet.pray') }} {{ selectedPray.n }}
<span v-if="selectedPray.praying" class="gm-pray-n">{{ fmt(selectedPray.praying) }} already praying</span>
</a>
<!-- A link when standalone, a filter when composed. Same words either way, because
the promise is the same; only the destination differs. -->
<button v-if="selectedGroup.iso639_3 && composed" type="button" class="gm-tablink"
@click="filterComposedTables({ iso: selectedGroup.iso639_3, label: selectedGroup.language || '' })">
{{ t('gospel.sheet.everyResource') }} {{ selectedGroup.language }} ↓
</button>
<a v-else-if="selectedGroup.iso639_3" class="gm-tablink"
:href="tableLink({ iso: selectedGroup.iso639_3 || '', label: selectedGroup.language || '' })">
{{ t('gospel.sheet.everyResource') }} {{ selectedGroup.language }} →
</a>
<div class="gm-sec" v-if="sameLanguage.length">
<div class="gm-sec-h">{{ t('gospel.sheet.alsoSpeak') }} {{ selectedGroup.language }} ({{ sameLanguage.length }})</div>
<button v-for="g in sameLanguage.slice(0, 12)" :key="g.people_group + g.countries"
type="button" class="gm-pg gm-pg-btn" @click="openGroup(g)">
<span class="gm-pg-n">{{ g.people_group }}</span>
<span class="gm-pg-l">{{ fmt(g.population) }} people · {{ g.countries }}</span>
</button>
<div v-if="sameLanguage.length > 12" class="gm-more">
Showing 12 of {{ sameLanguage.length }} groups sharing this language.
</div>
</div>
<p class="gm-note" dir="ltr">
Matched on ISO 639-3 <b>{{ selectedGroup.iso639_3 || '—' }}</b> — an exact code match, never a
language-name match, because names collide across unrelated languages.
Checked: {{ selectedGroup.checked_pools || '—' }}. Not checked:
{{ selectedGroup.unchecked_pools || '—' }} — “none found” means absent from the pools checked,
never proof that nothing exists.
<template v-if="selectedGroup.code_note"><br>{{ selectedGroup.code_note }}</template>
</p>
</template>
<!-- ITEM 1 — a country: the aggregates, then the ACTUAL resources behind them. -->
<template v-else>
<div class="gm-sh-head">
<span class="gm-sh-name">{{ selected.country }}</span>
<button type="button" class="gm-x" @click="closeSheet">{{ t('gospel.sheet.close') }}</button>
</div>
<div class="gm-stat"><span>{{ t('gospel.stat.peopleGroups') }}</span><b>{{ fmt(countryStats.groups) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.noResource') }}</span><b>{{ fmt(countryStats.none) }} ({{ countryStats.pctNone }}%)</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.peopleAffected') }}</span><b>{{ fmt(countryStats.popNone) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.haveScripture') }}</span><b>{{ fmt(countryStats.scripture) }}</b></div>
<div class="gm-stat"><span>{{ t('gospel.stat.haveFilm') }}</span><b>{{ fmt(countryStats.film) }}</b></div>
<!-- Audio Scripture is counted here for the same reason it is chipped below: without
it the summary said a country had two media when it often has three, and the
rows underneath already showed the third. -->
<div class="gm-stat"><span>{{ t('gospel.stat.haveAudio') }}</span><b>{{ fmt(countryStats.audio) }}</b></div>
<div class="gm-stat" v-if="countryStats.sign"><span>{{ t('gospel.stat.signLanguages') }}</span><b>{{ fmt(countryStats.sign) }}</b></div>
<!-- These two are NOT derivable from the pins: they come from the curated country
analysis, over its own narrower set of groups. Labelled so the different basis is
visible rather than implied.
SHOWN ONLY WHERE THAT ANALYSIS EXISTS. 72 of the 242 countries here have no
curated row, and these rendered for them anyway — "Est. deaths/day 0", which is
not a measurement of anything. An absent figure drawn as a zero is worse than a
missing row, because a zero is a claim. -->
<template v-if="hasCurated">
<div class="gm-stat gm-stat-alt"><span>{{ t('gospel.stat.growingUnreached') }}<sup>†</sup></span><b>{{ fmt(selected.growing_unreached) }}</b></div>
<div class="gm-stat gm-stat-alt"><span>{{ t('gospel.stat.deathsDay') }}<sup>†</sup></span><b>{{ fmt(Math.round(selected.deaths_day || 0)) }}</b></div>
<p class="gm-note" dir="ltr" style="margin:4px 0 0">
<sup>†</sup> from the curated country analysis, over
<template v-if="selected.curated_groups">{{ fmt(selected.curated_groups) }} of these
{{ fmt(countryStats.groups) }} groups</template><template v-else>a different set of
groups</template> — not the pins, so it is not a share of the numbers above.
</p>
</template>
<!-- Same split as the language link above: a filter when the tables are already on
screen beneath this map, a link when they are not. This is the brief's SECOND
requirement — click a country, see its resources — and in the composite it was
answering by leaving the composite. -->
<button v-if="selected.iso3 && composed" type="button" class="gm-tablink"
@click="filterComposedTables({ country: selected.iso3, label: selected.country || '' })">
Every resource used in {{ selected.country }} ↓
</button>
<a v-else-if="selected.iso3" class="gm-tablink"
:href="tableLink({ country: selected.iso3 || '', label: selected.country || '' })">
Every resource used in {{ selected.country }} →
</a>
<div v-if="pgLoading" class="gm-load">Loading resources…</div>
<div v-else-if="pgErr" class="gm-err" style="position:static;text-align:left">{{ pgErr }}</div>
<template v-else>
<div class="gm-sec">
<div class="gm-sec-h">Gospel resources found ({{ withResources.length }})</div>
<div v-if="!withResources.length" class="gm-none">
Nothing found for any group here in the pools checked.
</div>
<!-- NOT a <button> wrapper. The chips below are real <a href> links to the
resource itself, and HTML forbids interactive content inside a button —
a nested anchor is hoisted out by the parser and stops being a link.
So the row is a plain container: the NAME is the drill-in control, the
CHIPS are the outbound links, and the two no longer fight. -->
<div v-for="g in withResources.slice(0, shown)" :key="'r' + g.people_group + g.language"
class="gm-pg">
<button type="button" class="gm-pg-open" @click="openGroup(g)">
<span class="gm-pg-n">{{ g.people_group }}</span>
<span class="gm-pg-l">{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code></span>
</button>
<span class="gm-chips">
<!-- The URL the verification pass already proved. Surfacing it is the
whole point: a chip that SAYS "link checked" while hiding the link
costs middle-click, open-in-new-tab, copy-link-address and link
semantics for screen readers. -->
<a v-if="g.scripture_url" class="gm-chip" :class="vchip(g.scripture_url).cls"
:href="g.scripture_url" target="_blank" rel="noopener noreferrer"
:title="vtitle(g.scripture_url)">
{{ t('gospel.res.scripture') }} · {{ t(vchip(g.scripture_url).labelKey) }}
</a>
<a v-if="g.film_url" class="gm-chip" :class="vchip(g.film_url).cls"
:href="g.film_url" target="_blank" rel="noopener noreferrer"
:title="vtitle(g.film_url)">
{{ t('gospel.res.film') }} · {{ t(vchip(g.film_url).labelKey) }}
</a>
<!-- The other three media. A country view that shows only Scripture and film
under-reports what is actually reachable there, and the pin sheet for the
same group would have shown all of them. -->
<a v-if="g.audio" class="gm-chip ok wrap" :href="g.audio.u"
target="_blank" rel="noopener noreferrer"
:title="g.audio.n + ' — Faith Comes By Hearing (' + (g.audio.m || 'audio') + ')'">
Scripture, {{ g.audio.m || 'audio' }}
</a>
<a v-if="g.bible_video" class="gm-chip ok wrap" :href="g.bible_video.u"
target="_blank" rel="noopener noreferrer"
:title="g.bible_video.n + ' — Bible narrative video. This is NOT the JESUS Film. Covers: ' + (g.bible_video.sc || 'see site')">
Bible video
</a>
<a v-if="g.zume_url" class="gm-chip ok" :href="g.zume_url"
target="_blank" rel="noopener noreferrer"
title="Zume Training publishes its discipleship course in this language.">
Training
</a>
</span>
</div>
<div v-if="withResources.length > shown" class="gm-more">
Showing {{ shown }} of {{ withResources.length }}.
<button type="button" class="gm-back" @click="shown = withResources.length">Show all groups</button>
</div>
</div>
<div class="gm-sec" v-if="withoutResources.length">
<div class="gm-sec-h">No resource found ({{ withoutResources.length }})</div>
<!-- These groups have NO verified URL, so NOTHING here becomes an anchor.
A dead <a> that navigates nowhere would claim a resource exists — the
exact overclaim this map refuses everywhere else. The row stays a plain
drill-in button and keeps saying, honestly, that nothing was found. -->
<button v-for="g in withoutResources.slice(0, shown)" :key="'n' + g.people_group + g.language"
type="button" class="gm-pg gm-pg-btn" @click="openGroup(g)">
<span class="gm-pg-n">{{ g.people_group }}</span>
<span class="gm-pg-l">
{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code>
· {{ fmt(g.population) }} people
</span>
</button>
<div v-if="withoutResources.length > shown" class="gm-more">
Showing {{ shown }} of {{ withoutResources.length }}.
<button type="button" class="gm-back" @click="shown = withoutResources.length">Show all groups</button>
</div>
</div>
</template>
<p class="gm-note" dir="ltr">
Deaths/day applies a national crude death rate to a sub-population — order of magnitude, not a count.
<!-- This footnote still told readers audio was key-blocked and unchecked, long after
Faith Comes By Hearing and GRN were both searched. A caveat that has stopped
being true is not a safe default — it understates what the map can show. -->
“No resource found” means not present in any pool checked: eBible, PNG.Bible, the
JESUS Film catalogue, Door43, Faith Comes By Hearing, Scripture Earth and Global
Recordings Network. Audio <b>is</b> searched — GRN through their 5fish site, at the
crawl delay they ask for, with every language in the gap now decided. API.Bible, the
Digital Bible Library, progress.bible and YouVersion need credentials or permission
this build does not have.
<br>Link states: <b>link checked</b> = the target resolves to the language claimed.
Scripture links are fetched from eBible directly. Film links are checked against the Arclight
catalogue, not jesusfilm.org — that page is client-rendered and returns an identical page for a
bogus language, so fetching it could only ever produce a false pass.
<b>unchecked</b> is never evidence that a link works or fails.
</p>
</template>
</div>
</div>
</template>