The brief names zume.training every tick and an earlier pass recorded the list as unobtainable. That was wrong. robots.txt Disallows /api/ and the homepage switcher is client-rendered, both true, but robots.txt also DECLARES a sitemap, and the publisher maintains /articles/zume-languages for exactly this purpose. Neither is behind a Disallow. The real figure is 62 locales, not the ~40 estimated: 54 serve a landing page that answers 200. The two sources disagree in both directions - Dari and Pashto ship sites but are missing from the article's table, and eight table rows serve no page yet - so fetch-zume-index.mjs takes the union and then fetches every landing URL. A locale ships a link only if its own page answered. Naming and linking stay separate facts. Pashto is deliberately left unjoined: pus is a macrolanguage, the people-group data carries pbu, pbt and pst, and nothing the publisher gives selects one. Training is not Scripture and not film, so it sits in its own field and its own type and moves NO tier. Measured: the 52 joined languages reach 3,479 pins and 4.78bn people, and none of them are tier 0 - every Zume language already has Scripture or film. Folding training into the tiers would have cut the gap count without anyone having translated anything. The gap filter still cuts 12,379 features to 3,457. Both gates had a hole this exposed. The coverage gate harvested URLs from s, f and d only, so the 52 new URLs existed in no pin record it could see; and clause I checked Film, Scripture and Bible Stories by host but let any other type fall through unchecked. Both now cover training, and a poisoned row typed Scripture on zume.training is caught. The browser gate screened 3,550 links and silently ignored the new ones; it now screens all 3,602. Verified in a browser with a negative control: deu opens with a chip linking https://zume.training/de, kri opens with "not in Zume's 54" and no href, no page errors.
1139 lines
62 KiB
Vue
1139 lines
62 KiB
Vue
<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, film, Bible-story and Zume 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 3,457 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 an AVERAGE across a country's groups and the dots are
|
||
* individual groups, which is why a red dot can sit on a green country — that case is
|
||
* the point of the map, not a rendering fault.
|
||
*
|
||
* > 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 DX people groups whose language has NO
|
||
* scripture and NO film IN THE POOLS CHECKED (eBible + Arclight). GRN audio is
|
||
* key-blocked and unchecked; so are DBL and bible.com.
|
||
*
|
||
* 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 { 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'
|
||
|
||
const profileConfig = inject('profileConfig', ref(null))
|
||
const isDark = computed(() => profileConfig.value?.isDark === true)
|
||
const mapboxToken = computed(() => profileConfig.value?.tk || window.DOXA_TOKEN || '')
|
||
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.
|
||
const BANDS = [
|
||
{ id: 'critical', label: '60%+ with nothing found', color: '#b42318' },
|
||
{ id: 'high', label: '30–59%', color: '#e07a2b' },
|
||
{ id: 'partial', label: 'some groups', color: '#e8c15a' },
|
||
{ id: 'covered', label: 'all groups have some', color: '#10b981' },
|
||
]
|
||
// Same four colours read the other way round: green now means "most groups have
|
||
// something found", so the eye is not asked to re-learn the palette per mode.
|
||
const COVER_BANDS = [
|
||
{ id: 'c-high', label: '80%+ have something found', color: '#10b981' },
|
||
{ id: 'c-mid', label: '50–79%', color: '#e8c15a' },
|
||
{ id: 'c-low', label: '1–49%', color: '#e07a2b' },
|
||
{ id: 'c-none', label: 'nothing found for any', color: '#b42318' },
|
||
]
|
||
const activeBands = computed(() => (mode.value === 'gap' ? BANDS : COVER_BANDS))
|
||
|
||
// PIN TIERS reuse the band hexes above rather than declaring a fourth palette, so a pin
|
||
// and the country under it never disagree about what "nothing found" looks like. Read
|
||
// straight off BANDS so editing a band moves the pins with it.
|
||
const PIN_COLOR = {
|
||
2: BANDS[3].color, // Scripture AND film in their own language
|
||
1: BANDS[2].color, // one of the two
|
||
0: BANDS[0].color, // nothing found in the pools checked — the pin that matters most
|
||
}
|
||
|
||
|
||
const bandColor = (b) => (BANDS.find((x) => x.id === b) || {}).color || '#9aa2ad'
|
||
// Share of a country's groups with ANY resource found, derived from the same counts
|
||
// the gap band uses — so the two modes can never disagree about the same country.
|
||
const coverPct = (d) => {
|
||
const g = Number(d?.groups) || 0
|
||
if (!g) return null
|
||
return ((g - (Number(d?.no_resource) || 0)) / g) * 100
|
||
}
|
||
const coverColor = (d) => {
|
||
const p = coverPct(d)
|
||
if (p === null) return '#9aa2ad'
|
||
if (p >= 80) return '#10b981'
|
||
if (p >= 50) return '#e8c15a'
|
||
if (p > 0) return '#e07a2b'
|
||
return '#b42318'
|
||
}
|
||
const featureColor = (d) => (mode.value === 'gap' ? bandColor(d.band) : coverColor(d))
|
||
|
||
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 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 pinCodes = ref({}) // ISO 639-3 -> { l, s, f, a, o, ck, un }
|
||
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.
|
||
const pinFilter = ref('all') // all | none | some | both
|
||
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]
|
||
: ['==', ['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]
|
||
if (r[6] === 0) out.push({ i, name: r[0], lat: r[1], lng: r[2], iso3: r[3], code: r[4], pop: r[5] })
|
||
}
|
||
out.sort((a, b) => b.pop - a.pop)
|
||
return out.slice(0, 40)
|
||
})
|
||
function openGapList() { showGapList.value = true; selected.value = null; selectedGroup.value = null }
|
||
function closeGapList() { showGapList.value = false }
|
||
// Fly first, THEN open: openPin sets `selected`, which swaps the sheet away from this list,
|
||
// so doing it the other way round loses the map movement behind a re-render.
|
||
function gotoGap(g) {
|
||
const m = mapRef.value
|
||
if (m) m.flyTo({ center: [g.lng, g.lat], zoom: Math.max(m.getZoom(), 4.2), duration: 900 })
|
||
showGapList.value = false
|
||
openPin(g.i)
|
||
}
|
||
|
||
// 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]
|
||
for (const r of pinRows.value) t[r[6]]++
|
||
return { none: t[0], partial: t[1], both: t[2], 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.
|
||
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.
|
||
function groupAt(i) {
|
||
const r = pinRows.value[i]
|
||
if (!r) return null
|
||
const c = pinCodes.value[r[4]] || {}
|
||
return {
|
||
_i: i,
|
||
people_group: r[0],
|
||
language: c.l || '',
|
||
iso639_3: r[4] || '',
|
||
population: r[5],
|
||
countries: byIso.value[r[3]]?.country || r[3],
|
||
scripture_url: c.s || '',
|
||
film_url: c.f || '',
|
||
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,
|
||
zume_url: c.z || '',
|
||
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.',
|
||
}
|
||
}
|
||
|
||
const hasAny = (r) => !!(r.scripture_url || r.film_url || (r.door43 && r.door43.length))
|
||
|
||
// 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, none = 0, popNone = 0, sign = 0
|
||
for (const r of g) {
|
||
if (r.scripture_url) scripture++
|
||
if (r.film_url) film++
|
||
if (!hasAny(r)) { none++; popNone += r.population || 0 }
|
||
if (r.is_sign) sign++
|
||
}
|
||
return {
|
||
groups: g.length, scripture, film, 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
|
||
const VSTATE = {
|
||
verified: { cls: 'ok', label: 'link checked' },
|
||
broken: { cls: 'bad', label: 'link dead' },
|
||
mismatch: { cls: 'warn', label: 'wrong language' },
|
||
unknown: { cls: 'meh', label: '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] = 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()),
|
||
])
|
||
rows.value = data.countries || []
|
||
pinCodes.value = pinData.codes || {}
|
||
pinRows.value = pinData.pins || []
|
||
pinCount.value = pinRows.value.length
|
||
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',
|
||
color: d ? featureColor(d) : 'rgba(0,0,0,0)',
|
||
hasData: !!d,
|
||
},
|
||
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)
|
||
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',
|
||
properties: { i, tier: p[6], color: PIN_COLOR[p[6]] },
|
||
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',
|
||
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,
|
||
// 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],
|
||
},
|
||
})
|
||
// 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
|
||
const country = byIso.value[r[3]]
|
||
if (country) { selected.value = country; shown.value = PAGE; ensurePeopleGroups() }
|
||
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 () => {
|
||
const gl = window.mapboxgl
|
||
if (!gl || !mapEl.value) { err.value = 'mapbox-gl not loaded'; return }
|
||
try {
|
||
const adm0 = await load()
|
||
const map = new gl.Map({
|
||
container: mapEl.value,
|
||
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()}`
|
||
}
|
||
|
||
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:12.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:11px;white-space:nowrap}' +
|
||
'.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:11px;' +
|
||
'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:10.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:11px;font-weight:600;margin-bottom:4px}' +
|
||
'.gm-caveat{margin-top:5px;font-size:10.5px;opacity:.72;line-height:1.4}' +
|
||
// DETAIL: a bottom sheet on a phone, a corner card on desktop.
|
||
'.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:15px}' +
|
||
'.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:11px;line-height:1.5;opacity:.75}' +
|
||
'.gm-err{position:absolute;top:50%;left:0;right:0;text-align:center;font-size:13px;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:16px;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:11px;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:10.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:11.5px;line-height:1.45;color:#3b463d}' +
|
||
'.gm-scope b{font-weight:700}' +
|
||
'.is-dark .gm-scope{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:11.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-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:12.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:12px;letter-spacing:.02em;margin-bottom:6px}' +
|
||
'.gm-load{padding:10px 0;font-size:11.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}' +
|
||
// 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:12.5px}' +
|
||
'.gm-pg-l{display:block;font-size:11px;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}' +
|
||
'.gm-chip{font-size:10px;padding:2px 7px;border-radius:999px;white-space:nowrap;' +
|
||
'background:rgba(0,0,0,.06);text-decoration:none;color:inherit;border:1px solid transparent}' +
|
||
'.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)}' +
|
||
// 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:11px;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:11px;opacity:.66;margin-top:2px}',
|
||
'doxa-gospel-map',
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="gm" :class="{ 'is-dark': isDark }">
|
||
<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">
|
||
<input type="search" class="gm-q" v-model="q" placeholder="Find a people group, language or ISO code"
|
||
aria-label="Find a people group, language or ISO code">
|
||
<button v-if="q" type="button" class="gm-qx" @click="q = ''" aria-label="Clear search">×</button>
|
||
</div>
|
||
<div v-if="q.trim().length >= 2" class="gm-qres">
|
||
<div v-if="!matches.length" class="gm-qnone">Nothing matches that name, language or code.</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')">Gap</button>
|
||
<button type="button" class="gm-mode" :class="{ on: mode === 'coverage' }" @click="setMode('coverage')">Resources found</button>
|
||
</div>
|
||
<button v-if="narrow" type="button" class="gm-fold" @click="legendOpen = !legendOpen"
|
||
:aria-expanded="legendOpen ? 'true' : 'false'">
|
||
{{ legendOpen ? 'Hide key & notes' : 'Key & notes' }}
|
||
</button>
|
||
<div v-show="!narrow || legendOpen" class="gm-title">
|
||
{{ mode === 'gap'
|
||
? 'Unengaged groups with no gospel resource found'
|
||
: 'Unengaged groups with some gospel resource found' }}
|
||
</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>{{ b.label }}
|
||
</span>
|
||
</div>
|
||
<div v-show="!narrow || legendOpen" class="gm-pinkey" v-if="pinTally.total">
|
||
<div class="gm-pinkey-h">
|
||
Each <i class="gm-dot" style="background:#6d766e"></i> is ONE people group —
|
||
{{ fmt(pinTally.total) }} of them, {{ fmt(pinTally.linked) }} with a link
|
||
</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>Scripture <em>and</em> film
|
||
<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>some resource found
|
||
<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>nothing found
|
||
<b>{{ fmt(pinTally.none) }}</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' : 'groups with Scripture and film' }}</b>.
|
||
<button type="button" class="gm-kclear" @click="setPinFilter('all')">Show all {{ fmt(pinTally.total) }}</button>
|
||
<button v-if="pinFilter === 'none'" type="button" class="gm-kclear" @click="openGapList">
|
||
See the 40 largest →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-show="!narrow || legendOpen" class="gm-caveat">
|
||
<b>A dot and the country under it mean different things.</b> The country is an AVERAGE
|
||
across its groups; a dot is that ONE group's own language. So a red dot can sit on a green
|
||
country — that is the case worth finding, not a rendering fault.
|
||
<br>“None found” = not in the pools checked (eBible, JESUS Film, Door43). <b>Audio is unchecked for
|
||
every group here</b> — GRN needs credentials — so for an oral culture this map has not yet
|
||
looked where it matters most. 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>
|
||
<br>Either way the colour is only a country average — tap a country to see the actual resources, group by group.
|
||
</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">Largest groups with nothing found</span>
|
||
<button type="button" class="gm-x" @click="closeGapList">Close</button>
|
||
</div>
|
||
<p class="gm-note" style="margin-top:0">
|
||
{{ fmt(pinTally.none) }} people groups have no Scripture, film or Door43 resource in the
|
||
pools checked — {{ fmt(gapPeople) }} people. These are the 40 largest; tap one to fly there.
|
||
<br><b>Audio has never been searched for any of them.</b> GRN is the main audio-gospel
|
||
pool and needs credentials this build does not have, so for an oral culture the most
|
||
relevant resource is the one pool nobody has looked in.
|
||
</p>
|
||
<button v-for="g in gapList" :key="g.i" type="button" class="gm-pg gm-pg-btn" @click="gotoGap(g)">
|
||
<span class="gm-pg-n">{{ g.name }}</span>
|
||
<span class="gm-pg-l">{{ fmt(g.pop) }} people · {{ g.iso3 }}<code class="gm-code">{{ g.code || 'no code' }}</code></span>
|
||
</button>
|
||
</template>
|
||
|
||
<!-- ITEM 2 — one people group: what exists in THEIR OWN language. -->
|
||
<template v-else-if="selectedGroup">
|
||
<div class="gm-sh-head">
|
||
<button type="button" class="gm-back" @click="selectedGroup = null">‹ {{ selected.country }}</button>
|
||
<button type="button" class="gm-x" @click="closeSheet">Close</button>
|
||
</div>
|
||
<div class="gm-sh-name" style="margin-bottom:6px">{{ selectedGroup.people_group }}</div>
|
||
<div class="gm-stat">
|
||
<span>Language</span>
|
||
<b>{{ selectedGroup.language }}<code class="gm-code">{{ selectedGroup.iso639_3 || 'no code' }}</code></b>
|
||
</div>
|
||
<div class="gm-stat"><span>Population</span><b>{{ fmt(selectedGroup.population) }}</b></div>
|
||
<div class="gm-stat"><span>Countries</span><b>{{ selectedGroup.countries }}</b></div>
|
||
|
||
<div class="gm-sec">
|
||
<div class="gm-sec-h">In their own language</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)">
|
||
Scripture · {{ vchip(selectedGroup.scripture_url).label }}
|
||
</a>
|
||
<span v-else class="gm-chip meh">Scripture · none found</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.'">
|
||
Film · {{ vchip(selectedGroup.film_url).label }}
|
||
</a>
|
||
<span v-else class="gm-chip meh">Film · none found</span>
|
||
|
||
<!-- has_audio ships the STRING 'unknown' for key-blocked pools. It is
|
||
rendered as "unknown", never collapsed to a no. -->
|
||
<!-- "unknown" was doing no work: it is unknown for EVERY group on this map,
|
||
because the audio pool has never been searched at all. Naming the pool
|
||
turns a shrug into something a reader can act on. -->
|
||
<span class="gm-chip meh" :title="selectedGroup.has_audio === null || selectedGroup.has_audio === 'unknown'
|
||
? 'GRN (Global Recordings Network) is the main audio-gospel pool and has not been searched for any group on this map.' : ''">
|
||
Audio · {{ selectedGroup.has_audio === true ? 'found'
|
||
: selectedGroup.has_audio === false ? 'none found' : 'not searched (GRN)' }}
|
||
</span>
|
||
<!-- 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"
|
||
: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">
|
||
Open Bible Stories · none found
|
||
</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.">
|
||
Discipleship training · Zume
|
||
</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.">
|
||
Discipleship training · not in Zume's 54
|
||
</span>
|
||
</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>
|
||
|
||
<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.
|
||
</p>
|
||
</div>
|
||
|
||
<a class="gm-tablink" :href="tableLink({ iso: selectedGroup.iso639_3 || '', label: selectedGroup.language || '' })"
|
||
v-if="selectedGroup.iso639_3">
|
||
Every resource in {{ selectedGroup.language }} →
|
||
</a>
|
||
|
||
<div class="gm-sec" v-if="sameLanguage.length">
|
||
<div class="gm-sec-h">Also speak {{ 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">
|
||
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">Close</button>
|
||
</div>
|
||
<div class="gm-stat"><span>People groups</span><b>{{ fmt(countryStats.groups) }}</b></div>
|
||
<div class="gm-stat"><span>No resource found</span><b>{{ fmt(countryStats.none) }} ({{ countryStats.pctNone }}%)</b></div>
|
||
<div class="gm-stat"><span>People affected</span><b>{{ fmt(countryStats.popNone) }}</b></div>
|
||
<div class="gm-stat"><span>Have scripture</span><b>{{ fmt(countryStats.scripture) }}</b></div>
|
||
<div class="gm-stat"><span>Have film</span><b>{{ fmt(countryStats.film) }}</b></div>
|
||
<div class="gm-stat" v-if="countryStats.sign"><span>Sign languages</span><b>{{ fmt(countryStats.sign) }}</b></div>
|
||
<!-- These two are NOT derivable from the pins: they come from the curated
|
||
country analysis, which covers a smaller set of groups. Labelled so the
|
||
different basis is visible rather than implied. -->
|
||
<div class="gm-stat gm-stat-alt"><span>Growing & unreached<sup>†</sup></span><b>{{ fmt(selected.growing_unreached) }}</b></div>
|
||
<div class="gm-stat gm-stat-alt"><span>Est. deaths/day<sup>†</sup></span><b>{{ fmt(Math.round(selected.deaths_day || 0)) }}</b></div>
|
||
<p class="gm-note" style="margin:4px 0 0">
|
||
<sup>†</sup> from the curated country analysis, which covers
|
||
{{ fmt(selected.groups) }} of these {{ fmt(countryStats.groups) }} groups — a narrower
|
||
set than the pins, so it is not a share of the numbers above.
|
||
</p>
|
||
|
||
<a class="gm-tablink" :href="tableLink({ country: selected.iso3 || '', label: selected.country || '' })"
|
||
v-if="selected.iso3">
|
||
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)">
|
||
Scripture · {{ vchip(g.scripture_url).label }}
|
||
</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)">
|
||
Film · {{ vchip(g.film_url).label }}
|
||
</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</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</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<p class="gm-note">
|
||
Deaths/day applies a national crude death rate to a sub-population — order of magnitude, not a count.
|
||
“No resource found” means not present in eBible or the JESUS Film catalogue; audio pools are key-blocked and unchecked.
|
||
<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>
|